From d8ee2faeb18d49f42504593fa545cd6b950a3a80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 02:54:13 +0000 Subject: [PATCH 1/9] Add the statechart skill to pstack Draws what a user sees as a Mermaid statechart generated from a TLA+ model. TLC checks the reader's rules as invariants. Each state is marked works, still wrong, or fixed by an open change, and each counterexample class is drawn as a numbered path. The skill also turns an agreed chart into a TLA+ skeleton and compares the drawn chart with the agreed one. Bumps pstack to 0.15.30 and regenerates the Claude Code harness tree. --- .../claude-code/.claude-plugin/plugin.json | 2 +- harness/pstack/claude-code/README.md | 1 + .../docs/guide/06-verify-and-ship.md | 12 + harness/pstack/claude-code/plugin.json | 2 +- .../claude-code/skills/poteto-help/SKILL.md | 1 + .../claude-code/skills/statechart/SKILL.md | 115 ++++++ .../statechart/scripts/chart-to-tla.mjs | 164 ++++++++ .../statechart/scripts/compare-charts.mjs | 79 ++++ .../skills/statechart/scripts/lib.mjs | 360 ++++++++++++++++ .../scripts/test/statechart.test.mjs | 176 ++++++++ .../skills/statechart/scripts/test/sync.mmd | 19 + .../skills/statechart/scripts/test/upload.mmd | 16 + .../statechart/scripts/tlc-to-chart.mjs | 390 ++++++++++++++++++ pstack/.cursor-plugin/plugin.json | 2 +- pstack/README.md | 1 + pstack/docs/guide/06-verify-and-ship.md | 12 + pstack/plugin.json | 2 +- pstack/skills/poteto-help/SKILL.md | 1 + pstack/skills/statechart/SKILL.md | 115 ++++++ .../statechart/scripts/chart-to-tla.mjs | 164 ++++++++ .../statechart/scripts/compare-charts.mjs | 79 ++++ pstack/skills/statechart/scripts/lib.mjs | 360 ++++++++++++++++ .../scripts/test/statechart.test.mjs | 176 ++++++++ .../skills/statechart/scripts/test/sync.mmd | 19 + .../skills/statechart/scripts/test/upload.mmd | 16 + .../statechart/scripts/tlc-to-chart.mjs | 390 ++++++++++++++++++ 26 files changed, 2670 insertions(+), 4 deletions(-) create mode 100644 harness/pstack/claude-code/skills/statechart/SKILL.md create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/lib.mjs create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/test/sync.mmd create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/test/upload.mmd create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs create mode 100644 pstack/skills/statechart/SKILL.md create mode 100644 pstack/skills/statechart/scripts/chart-to-tla.mjs create mode 100644 pstack/skills/statechart/scripts/compare-charts.mjs create mode 100644 pstack/skills/statechart/scripts/lib.mjs create mode 100644 pstack/skills/statechart/scripts/test/statechart.test.mjs create mode 100644 pstack/skills/statechart/scripts/test/sync.mmd create mode 100644 pstack/skills/statechart/scripts/test/upload.mmd create mode 100644 pstack/skills/statechart/scripts/tlc-to-chart.mjs diff --git a/harness/pstack/claude-code/.claude-plugin/plugin.json b/harness/pstack/claude-code/.claude-plugin/plugin.json index 2b28f689c..4c3918502 100644 --- a/harness/pstack/claude-code/.claude-plugin/plugin.json +++ b/harness/pstack/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pstack", - "version": "0.15.29", + "version": "0.15.30", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", "author": { "name": "Lauren Tan" diff --git a/harness/pstack/claude-code/README.md b/harness/pstack/claude-code/README.md index a6c4926e3..47c496ed1 100644 --- a/harness/pstack/claude-code/README.md +++ b/harness/pstack/claude-code/README.md @@ -151,6 +151,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it | [`/maintain-verification-skill`](./skills/maintain-verification-skill/SKILL.md) | your verify skill's feature map has drifted from the app. source wave + one live pass, at most one PR of proven corrections. | | [`/create-formal-verification-skill`](./skills/create-formal-verification-skill/SKILL.md) | tests can't reach the case that breaks a race, retry, state machine, parser, or unsafe code. verifies one property with the tool that fits and generates a project-local skill to recheck it on every change. | | [`/maintain-formal-verification-skill`](./skills/maintain-formal-verification-skill/SKILL.md) | code your formal verification covers has changed since it was last checked. updates the model, harness, or properties to match the code, reruns, and says whether each failure is drift in the verification or a bug in the code. | +| [`/statechart`](./skills/statechart/SKILL.md) | someone who owns the behavior needs to see it, not read a spec. draws what the user sees as a mermaid statechart generated from a tla+ model, marks each state works, still wrong, or fixed by your open pr, and draws each bug tlc finds as a numbered path. also turns an agreed chart into a tla+ skeleton. | | [`/unslop`](./skills/unslop/SKILL.md) | you're cleaning up writing. removes AI tells. | | [`/bro`](./skills/bro/SKILL.md) | you want the last message restated in plain human language, no jargon. | | [`/technical-writing`](./skills/technical-writing/SKILL.md) | layered doc standard (Diátaxis + Google developer style + STE + Global English) for docs, RFCs, readmes, PR descriptions, commit messages. | diff --git a/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md b/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md index 3c7b3c4dd..83735d7e5 100644 --- a/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md +++ b/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md @@ -115,6 +115,18 @@ It writes a project-local skill that tells the next agent whether a change touch The skill also records which code each check covers and the commit where the check last matched it. Merges and refactors that skip the skill can leave the check passing on a description of code that's gone. After them, run [`/maintain-formal-verification-skill`](../../skills/maintain-formal-verification-skill/SKILL.md). It updates the verification from the code, reruns it, shows that each updated check can still fail, and tells you whether each failure is drift in the verification or a bug in the code. +## Show the behavior as a statechart + +A counterexample in model terms convinces you. The PM or designer who owns the behavior needs to see it in their terms. Run: + +```text +/statechart what the user sees when they edit a note offline +``` + +[`/statechart`](../../skills/statechart/SKILL.md) draws a Mermaid statechart of what the user sees, generated from a TLA+ model, so the picture and the checked model can't drift apart. The reader's rules ("you never see Synced while you're offline") become invariants. Each bug TLC finds is a numbered path in the reader's words, and each state is marked works, still wrong, or fixed by your open PR. Before the hand-off, the agent reproduces each bug path in the real code, so a modeling error doesn't reach the reader as a bug. + +It also runs the other way. Sketch the behavior as a chart, agree on it with the reader, and the skill writes a TLA+ skeleton from it. TLC checks the agreed chart's rules before any code exists. As guards from the real code go into the spec, each place the code departs from the chart shows up as a difference for the reader to decide. + ## Open the PR ```text diff --git a/harness/pstack/claude-code/plugin.json b/harness/pstack/claude-code/plugin.json index 2b28f689c..4c3918502 100644 --- a/harness/pstack/claude-code/plugin.json +++ b/harness/pstack/claude-code/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pstack", - "version": "0.15.29", + "version": "0.15.30", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", "author": { "name": "Lauren Tan" diff --git a/harness/pstack/claude-code/skills/poteto-help/SKILL.md b/harness/pstack/claude-code/skills/poteto-help/SKILL.md index a2ac519b0..1719eceee 100644 --- a/harness/pstack/claude-code/skills/poteto-help/SKILL.md +++ b/harness/pstack/claude-code/skills/poteto-help/SKILL.md @@ -90,6 +90,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste | Bring a verification skill and its feature map back in line with the app | [`/maintain-verification-skill`](../maintain-verification-skill/SKILL.md) | | Formally verify a race, invariant, or other property that tests rarely reach | [`/create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) | | Bring a formal verification back in line after the code it covers changed, and learn whether a failure is drift or a bug | [`/maintain-formal-verification-skill`](../maintain-formal-verification-skill/SKILL.md) | +| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, or turn an agreed chart into a TLA+ skeleton | [`/statechart`](../statechart/SKILL.md) | | Vet a performance number before reporting or acting on it | [`/benchmark-checklist`](../benchmark-checklist/SKILL.md) | | Run a large or cross-cutting change, or one to review after stepping away | [`/figure-it-out`](../figure-it-out/SKILL.md) | | Keep a decision log during a run, and review it afterward | [`/show-me-your-work`](../show-me-your-work/SKILL.md) | diff --git a/harness/pstack/claude-code/skills/statechart/SKILL.md b/harness/pstack/claude-code/skills/statechart/SKILL.md new file mode 100644 index 000000000..56a8c7491 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/SKILL.md @@ -0,0 +1,115 @@ +--- +name: statechart +description: "Draw what a user sees as a Mermaid statechart generated from a TLA+ model. States are marked works, still wrong, or fixed, and each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." +disable-model-invocation: true +--- + +# Statechart + +A PM, a designer, or a reviewer can judge a chart of what they see. They cannot judge a TLA+ spec. This skill draws the chart from the model, so the chart and the checked model are one object. The reader's rules ("you never see X while Y") become invariants. TLC finds every path that breaks one. The chart shows each as a numbered path in the reader's words. + +## Pick the direction + +- **A model exists, or the code is concrete enough to model.** Go model to chart. If the project has a formal verification skill, start from its spec. A spec written for one property may not hold what the reader sees. Add those variables and keep its old invariants passing. A new spec follows the "true to the code" rules in [create-formal-verification-skill](../create-formal-verification-skill/SKILL.md). +- **No model, and the reader should agree on the behavior before code exists or changes.** Go chart to model. + +## Setup + +- Java 11 or later and `tla2tools.jar` from the [TLA+ releases](https://github.com/tlaplus/tlaplus/releases). Set `TLA2TOOLS` to the jar's path. +- Node. The scripts in `scripts/` next to this file have no dependencies. +- To render, `npx -y @mermaid-js/mermaid-cli -i chart.mmd -o chart.png -s 2`. + +Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. With `-continue`, TLC keeps going past a broken rule and exits 0, so read its output, not its exit code. + +```bash +tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -continue "$@"; } +``` + +## Model to chart + +1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. + +2. **Write the reader's rules as invariants.** One sentence the reader agrees to is one invariant, with the sentence as its comment. For "once everything settles", define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the reader does not take. Never weaken a rule to make the chart pass. A broken rule is the finding. + +3. **Write the mapping module.** It is an ES module that `tlc-to-chart.mjs` imports. + + ```js + export const readerActions = ["EditNote", "OpenApp"]; + // Reader words for one model state. facts.settled is true when only reader actions can happen. + export function state(v, facts) {} + // Edge label in reader words. Return "" to hide a step. + export function step(action, before, after) {} + // Why a bad state is bad, in code terms. + export function cause(v, facts, invariant) {} + // Developer words for one model state. Needed only for --view developer or layered. + export function detail(v, facts) {} + ``` + + Counterexamples that break the same rule with the same `cause` are one bug. Two bugs that end on the same screen through different code paths stay apart only if `cause` names the code path, such as "the badge read the cache before the server answered". + +4. **Run TLC.** One run dumps the state graph. Then run once per rule, because TLC reports only the first rule a state breaks. + + ```bash + tlc -dump dot,actionlabels Spec.dot -config Spec.cfg Spec.tla > Spec.out + for rule in NoStaleBadge NoLostEdit; do + { grep -v '^INVARIANT' Spec.cfg; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" + tlc -config "Spec-$rule.cfg" Spec.tla > "Spec-$rule.out" + echo "$rule: $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" + done + ``` + + If an open change fixes some of it, model the change as a second spec (`SpecFixed.tla`) and run the same commands on it. + +5. **Draw the reader's chart.** + + ```bash + node scripts/tlc-to-chart.mjs --dot Spec.dot --map map.mjs \ + --traces Spec-NoStaleBadge.out --traces Spec-NoLostEdit.out \ + --fixed-dot SpecFixed.dot \ + --fixed-traces SpecFixed-NoStaleBadge.out --fixed-traces SpecFixed-NoLostEdit.out \ + --cover --highlight 1 --title "Offline editing today" \ + --out chart.mmd --story stories.md --summary summary.json + ``` + + Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Draw one chart per bug for the hand-off, and one with `--highlight 0` for the overview. + + Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and redraw. + +6. **Show one bug's mechanism.** `--view developer --path-only --highlight N` draws bug N through the `detail` states. Do not draw a whole developer chart. On one model it had twelve times the states of the reader's chart. `--view layered` puts the developer states inside the reader states, for a reviewer who wants both on one page. + +7. **Reproduce every red path in the real code.** The model can be wrong. Replay each bug's story from `stories.md` as a test or a live repro against the real code, one step per model action. A bug you cannot reproduce is a modeling error until shown otherwise. Fix the model, not the rule. Report which bugs reproduced. + +## Chart to model + +1. **Write the chart** in Mermaid `stateDiagram-v2`. Split a top-level composite with `--` into regions. Each region is one variable. Each transition label is one action, and regions with the same label move together. Write each rule as a comment line. + + ``` + %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. + ``` + + Each name in a `never` line is a state, or a composite that stands for every state inside it. The names must come from different regions. The chart cannot hold history states. Draw "comes back to where it was" as an edge to that state. + +2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`. + +3. **Check the agreed chart.** Run step 4 of model to chart on `Sync`, then draw and compare. + + ```bash + node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out --highlight 0 --out drawn.mmd + node scripts/compare-charts.mjs chart.mmd drawn.mmd + ``` + + `compare-charts.mjs` prints `same` when the drawn chart has the same states and transitions. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. + +4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. + +## Read the chart + +- Gray states work today. Red states are still wrong, because the checks still reach a first bad state there with the fix in. Green states are fixed by the open change. Without `--fixed-*`, every bad state is red. +- A thick blue border and numbered labels mark the highlighted bug. Mermaid state diagrams cannot color an edge. +- An edge shows its two most common labels. "or …" means there are more. +- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. +- `summary.json` has the counts behind the chart. Quote it, not an estimate. + +## Hand off + +Give the reader the PNG of each chart and its Mermaid source. Add one line per bug with the rule it breaks, the reader's steps, whether it reproduced, and whether the open change fixes it. Attach `stories.md` and the commands that regenerate everything. Say what the model leaves out. diff --git a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs new file mode 100644 index 000000000..2721c88b5 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// Write a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. +// +// node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT] +// +// Each region becomes one variable whose values are the region's states. +// Each transition label becomes one action. Regions that share a label move together: +// every region that can take it does, the others stay. An unlabeled transition is its own action. +// Each "%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\ B). +// A name in a never line is a state, or a composite that stands for every state inside it. +// +// Writes NAME.tla, NAME.cfg, and NAME.map.mjs (for tlc-to-chart.mjs) into OUT, next to the chart by default. +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseMermaid } from "./lib.mjs"; + +const args = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (a === "--module") args.module = process.argv[++i]; + else if (a === "--dir") args.dir = process.argv[++i]; + else if (!a.startsWith("--") && !args.chart) args.chart = a; + else { + console.error(`unknown argument ${a}`); + process.exit(2); + } +} +if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) { + console.error("Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"); + process.exit(2); +} +const dir = args.dir ?? path.dirname(args.chart); +const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); + +const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]); +const taken = new Set(RESERVED); +function ident(words, lower) { + const parts = words.normalize("NFKD").replace(/[^A-Za-z0-9]+/g, " ").trim().split(" ").filter(Boolean); + let id = parts.map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)).join("") || "Step"; + if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; + let unique = id; + for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; + taken.add(unique); + return unique; +} +const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +const set = (xs) => `{${[...xs].map(str).join(", ")}}`; + +const vars = Object.entries(regions).map(([name, r]) => { + if (r.states.size === 0) throw new Error(`region ${name} has no states`); + if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); + return { name, v: ident(name, true), ...r }; +}); + +// One action per label. Unlabeled transitions get one action each. +const actions = []; +const byLabel = new Map(); +for (const [ri, r] of vars.entries()) { + for (const t of r.transitions) { + let a = t.label ? byLabel.get(t.label) : null; + if (!a) { + a = { label: t.label, moves: new Map() }; + actions.push(a); + if (t.label) byLabel.set(t.label, a); + } + if (!a.moves.has(ri)) a.moves.set(ri, []); + const moves = a.moves.get(ri); + if (!moves.some((m) => m.from === t.from && m.to === t.to)) moves.push({ from: t.from, to: t.to }); + } +} +for (const a of actions) { + const only = a.label ? null : [...a.moves.values()][0][0]; + a.name = ident(a.label || `${only.from} to ${only.to}`, false); +} + +const out = []; +const bullet = (indent, op, lines) => lines.map((l, i) => `${" ".repeat(indent)}${op} ${l}`); +out.push(`---- MODULE ${args.module} ----`); +out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); +out.push("\\* Each region is one variable and each transition label is one action."); +out.push("\\* Once you add guards and variables from the real code, this file is the source:"); +out.push("\\* draw the chart from it with tlc-to-chart.mjs and check it with compare-charts.mjs."); +out.push(""); +out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`); +out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`); +out.push(""); +for (const x of vars) out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); +out.push(""); +out.push("TypeOK ==", ...bullet(4, "/\\", vars.map((x) => `${x.v} \\in ${x.v}States`))); +out.push(""); +out.push("Init ==", ...bullet(4, "/\\", vars.map((x) => (x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`)))); +for (const a of actions) { + out.push(""); + if (a.label) out.push(`\\* ${a.label}`); + out.push(`${a.name} ==`); + const moving = [...a.moves.keys()]; + const conj = []; + if (moving.length > 1) conj.push(`(${moving.map((ri) => `${vars[ri].v} \\in ${set(new Set(a.moves.get(ri).map((m) => m.from)))}`).join(" \\/ ")})`); + const blocks = []; + for (const ri of moving) { + const v = vars[ri].v; + const ds = a.moves.get(ri).map((m) => `(${v} = ${str(m.from)} /\\ ${v}' = ${str(m.to)})`); + if (moving.length > 1) ds.push(`(${v} \\notin ${set(new Set(a.moves.get(ri).map((m) => m.from)))} /\\ UNCHANGED ${v})`); + blocks.push(ds); + } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); + for (const c of conj) out.push(` /\\ ${c}`); + for (const ds of blocks) { + out.push(` /\\ \\/ ${ds[0]}`); + for (const d of ds.slice(1)) out.push(` \\/ ${d}`); + } + if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); +} +out.push(""); +out.push(actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars"); +out.push(""); +out.push("Spec == Init /\\ [][Next]_vars"); + +function resolve(item) { + const hits = []; + for (const [ri, x] of vars.entries()) { + const inside = [...x.states].filter((s) => s === item || s.startsWith(`${item}.`)); + if (inside.length) hits.push({ ri, inside }); + } + if (hits.length === 0) throw new Error(`never line names "${item}", which is not a state in the chart`); + if (hits.length > 1) throw new Error(`never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`); + return hits[0]; +} +const invariants = []; +for (const n of nevers) { + if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); + taken.add(n.name); + const parts = n.states.map(resolve); + const regionsUsed = new Set(parts.map((p) => p.ri)); + if (regionsUsed.size !== parts.length) throw new Error(`never ${n.name} names two states of one region; one region is in one state at a time`); + const cond = parts.map((p) => (p.inside.length === 1 ? `${vars[p.ri].v} = ${str(p.inside[0])}` : `${vars[p.ri].v} \\in ${set(p.inside)}`)); + out.push(""); + if (n.sentence) out.push(`\\* ${n.sentence}`); + out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); + invariants.push(n.name); +} +out.push("", "====", ""); + +const cfg = ["SPECIFICATION Spec", "INVARIANT TypeOK", ...invariants.map((n) => `INVARIANT ${n}`), ""]; + +const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); +const map = [ + `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, + `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, + `const LABELS = ${JSON.stringify(labels, null, "\t")};`, + "", + 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', + "export const step = (action) => LABELS[action] ?? action;", + "", +]; + +fs.mkdirSync(dir, { recursive: true }); +fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); +console.error( + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), ${invariants.length} invariant(s) -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, +); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs new file mode 100644 index 000000000..b3c2df1a9 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Compare two Mermaid stateDiagram-v2 charts: states, start states, and labeled transitions. +// +// node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd +// +// Use it to check that a spec still draws the chart the reader agreed to: EXPECTED is the agreed chart, +// ACTUAL is what tlc-to-chart.mjs drew from the spec. When EXPECTED has parallel regions and ACTUAL +// has one, ACTUAL's states split on " | " into one state per region, in EXPECTED's region order +// (the order chart-to-tla.mjs's mapping writes them). An edge label "a or b" counts as two labels, +// and a highlighted path's step numbers ("2. ") are not part of the label. +// Exits 1 when the charts differ. +import fs from "node:fs"; +import process from "node:process"; +import { parseMermaid } from "./lib.mjs"; + +const [expectedFile, actualFile] = process.argv.slice(2); +if (!expectedFile || !actualFile) { + console.error("Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"); + process.exit(2); +} +const expected = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; +let actual = parseMermaid(fs.readFileSync(actualFile, "utf8")).regions; + +const names = Object.keys(expected); +if (names.length > 1 && Object.keys(actual).length === 1) { + const [only] = Object.values(actual); + const split = (s) => { + const parts = s.split(" | "); + if (parts.length !== names.length) throw new Error(`state "${s}" does not split into ${names.length} regions on " | "`); + return parts; + }; + actual = Object.fromEntries(names.map((n) => [n, { states: new Set(), initial: new Set(), transitions: [] }])); + for (const s of only.states) split(s).forEach((p, i) => actual[names[i]].states.add(p)); + for (const s of only.initial) split(s).forEach((p, i) => actual[names[i]].initial.add(p)); + for (const t of only.transitions) { + const [from, to] = [split(t.from), split(t.to)]; + names.forEach((n, i) => { + if (from[i] !== to[i]) actual[n].transitions.push({ from: from[i], to: to[i], label: t.label }); + }); + } +} + +const edgeSet = (r) => { + const out = new Set(); + for (const t of r.transitions) { + const labels = t.label.replace(/^\d+(, \d+)*\. /, "").replace(/ or …$/, "").split(" or "); + for (const l of labels) out.add(`${t.from} --> ${t.to}${l ? ` : ${l}` : ""}`); + } + return out; +}; +const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + +let differences = 0; +for (const name of new Set([...names, ...Object.keys(actual)])) { + const e = expected[name]; + const a = actual[name]; + if (!e || !a) { + console.log(`Region ${name}: only in ${e ? "EXPECTED" : "ACTUAL"}`); + differences++; + continue; + } + const rows = [ + ["states only in EXPECTED (no path from the start reaches them, or the spec renamed them)", minus(e.states, a.states)], + ["states only in ACTUAL", minus(a.states, e.states)], + ["start states only in EXPECTED", minus(e.initial, a.initial)], + ["start states only in ACTUAL", minus(a.initial, e.initial)], + ["transitions only in EXPECTED", minus(edgeSet(e), edgeSet(a))], + ["transitions only in ACTUAL", minus(edgeSet(a), edgeSet(e))], + ].filter(([, xs]) => xs.length > 0); + if (rows.length === 0) continue; + console.log(`Region ${name}:`); + for (const [title, xs] of rows) { + console.log(` ${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; + } +} +console.log(differences === 0 ? "same" : `${differences} difference(s)`); +process.exit(differences === 0 ? 0 : 1); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs new file mode 100644 index 000000000..03b569877 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -0,0 +1,360 @@ +// Shared parsing for the statechart scripts. Node built-ins only. + +// ---------- TLA+ values as TLC prints them ---------- + +export function parseValue(text) { + const src = text.trim(); + let i = 0; + const ws = () => { + while (i < src.length && /\s/.test(src[i])) i++; + }; + const peek = (s) => src.startsWith(s, i); + const eat = (s) => { + ws(); + if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); + i += s.length; + }; + const list = (close, item) => { + const out = []; + ws(); + if (peek(close)) { + i += close.length; + return out; + } + for (;;) { + out.push(item()); + ws(); + if (peek(",")) { + i++; + continue; + } + eat(close); + return out; + } + }; + const value = () => { + ws(); + if (peek("<<")) { + i += 2; + return list(">>", value); + } + if (peek("{")) { + i++; + return list("}", value); + } + if (peek("[")) { + i++; + const pairs = list("]", () => { + ws(); + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad record field at ${i}`); + i += m[0].length; + eat("|->"); + return [m[0], value()]; + }); + return Object.fromEntries(pairs); + } + if (peek("(")) { + i++; + const fn = {}; + for (;;) { + const k = value(); + eat(":>"); + fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); + ws(); + if (peek("@@")) { + i += 2; + continue; + } + eat(")"); + return fn; + } + } + if (peek('"')) { + i++; + let s = ""; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") i++; + s += src[i++]; + } + i++; + return s; + } + const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad value at ${i} in ${src}`); + i += m[0].length; + if (m[0] === "TRUE") return true; + if (m[0] === "FALSE") return false; + return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; + }; + try { + const v = value(); + ws(); + return i === src.length ? v : src; + } catch { + return src; + } +} + +// One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. +export function parseState(text) { + const lines = text.split("\n").filter((l) => l.trim() !== ""); + const conjuncts = []; + for (const line of lines) { + if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) conjuncts.push(line.replace(/^\s*\/\\ /, "")); + else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; + } + const vars = {}; + for (const c of conjuncts) { + const eq = c.indexOf(" = "); + if (eq < 0) continue; + vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); + } + return vars; +} + +export function stateKey(vars) { + return JSON.stringify(Object.keys(vars).sort().map((k) => [k, vars[k]])); +} + +// ---------- TLC -dump dot,actionlabels ---------- + +function unescapeDot(s) { + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const n = s[++i]; + out += n === "n" ? "\n" : n; + } else out += s[i]; + } + return out; +} + +export function parseDot(text) { + const nodes = new Map(); + const edges = []; + const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; + const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); + const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); + for (const line of text.split("\n")) { + let m = edgeRe.exec(line); + if (m) { + if (m[1] !== m[2]) edges.push({ from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }); + continue; + } + m = nodeRe.exec(line); + if (m) nodes.set(m[1], { id: m[1], vars: parseState(unescapeDot(m[2])), init: /style\s*=\s*filled/.test(m[3]) }); + } + if (nodes.size === 0) throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); + const missing = edges.find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + if (missing) throw new Error(`transition ${missing.from} -> ${missing.to} names a state the file does not define`); + return { nodes, edges }; +} + +// ---------- TLC error traces (one per violation; many with -continue) ---------- + +export function parseTraces(text) { + const traces = []; + let invariant = null; + let current = null; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const inv = /^Error: Invariant (\S+) is violated/.exec(line); + if (inv) { + invariant = inv[1]; + continue; + } + const head = /^State (\d+): <([^\s>(]+)/.exec(line); + if (!head) continue; + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + const step = { action: head[2] === "Initial" ? null : head[2], vars: parseState(body.join("\n")) }; + if (head[1] === "1") { + current = { invariant, steps: [] }; + traces.push(current); + } + current?.steps.push(step); + } + return traces; +} + +// ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- + +export function wrap(text, width = 26) { + const words = String(text).split(/\s+/); + const rows = []; + let row = ""; + for (const w of words) { + if (row && (row + " " + w).length > width) { + rows.push(row); + row = w; + } else row = row ? `${row} ${w}` : w; + } + if (row) rows.push(row); + return rows.join("
"); +} + +const unwrap = (s) => s.replace(//g, " ").replace(/\s+/g, " ").trim(); + +// The scripts draw their legend as a composite with this id. It is not part of the machine. +export const LEGEND_ID = "legend"; + +// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label}] } }, nevers }. +// Composite states flatten to "Parent.Child". A transition on a composite leaves from each state +// inside it that has no transition with the same label, and enters at the composite's [*] start. +// One top-level composite split by "--" makes one region per part. Regions nest only at the top. +export function parseMermaid(text) { + const names = new Map(); + const nevers = []; + const newPart = () => ({ children: [], transitions: [] }); + const root = { name: "", parts: [newPart()] }; + const stack = [root]; + const part = () => stack[stack.length - 1].parts.at(-1); + const display = (id) => names.get(id) ?? id; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); + if (never) { + nevers.push({ name: never[1], states: never[2].split("&").map((s) => s.trim()), sentence: never[3] ?? "" }); + continue; + } + if (!line || line.startsWith("%%") || /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line)) continue; + let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (!m) { + const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (plain) m = [line, null, plain[1], plain[2]]; + } + if (m) { + if (m[1] !== null) names.set(m[2], unwrap(m[1])); + if (m[3]) { + const node = { name: m[2], parts: [newPart()] }; + part().children.push(node); + stack.push(node); + } else part().children.push({ name: m[2], leaf: true }); + continue; + } + if (line === "}") { + if (stack.length === 1) throw new Error("unmatched } in the chart"); + stack.pop(); + continue; + } + if (line === "--") { + stack[stack.length - 1].parts.push(newPart()); + continue; + } + m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); + if (m) { + part().transitions.push({ from: m[1], to: m[2], label: m[3] ? unwrap(m[3]) : "" }); + continue; + } + m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); + if (m) { + names.set(m[1], unwrap(m[2])); + part().children.push({ name: m[1], leaf: true }); + continue; + } + throw new Error(`cannot read chart line: ${line}`); + } + if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + + // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. + // A composite's full name prefixes its children ("Parent.Child"). An id used only in a + // transition is a plain state in the part where it first appears. + function region(p) { + const out = { states: new Set(), initial: new Set(), transitions: [] }; + const info = new Map(); + const declared = new Set(); + (function collect(q) { + for (const c of q.children) { + if (c.name === LEGEND_ID) continue; + declared.add(c.name); + if (!c.leaf) for (const sub of c.parts) collect(sub); + } + })(p); + const transitions = []; + const lookup = (id) => { + const found = info.get(id); + if (!found) throw new Error(`transition names ${id}, which is not in this region`); + return found; + }; + function states(q, prefix, depth) { + const local = q.children.filter((c) => c.name !== LEGEND_ID); + for (const t of q.transitions) { + for (const end of [t.from, t.to]) { + if (end === "[*]" || declared.has(end)) continue; + declared.add(end); + local.push({ name: end, leaf: true }); + } + } + const all = []; + for (const c of local) { + if (info.has(c.name)) { + if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; + throw new Error(`state id ${c.name} is declared in two places`); + } + const full = `${prefix}${display(c.name)}`; + if (c.leaf) { + out.states.add(full); + info.set(c.name, { all: [full], initial: [full], depth }); + all.push(full); + continue; + } + if (c.parts.length > 1) throw new Error(`${display(c.name)} splits into regions below the top level; make each region a top-level region`); + const inner = states(c.parts[0], `${full}.`, depth + 1); + const initial = c.parts[0].transitions.filter((t) => t.from === "[*]").flatMap((t) => lookup(t.to).initial); + if (initial.length === 0) throw new Error(`${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`); + info.set(c.name, { all: inner, initial, depth }); + all.push(...inner); + } + for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); + return all; + } + states(p, "", 0); + for (const t of p.transitions) if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); + // The deepest source wins: a composite's transition leaves only from states inside it + // that have no deeper transition with the same label. + transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); + const handled = new Set(); + let pending = []; + let depth = null; + for (const t of transitions) { + const from = lookup(t.from); + if (from.depth !== depth) { + for (const h of pending) handled.add(h); + pending = []; + depth = from.depth; + } + for (const f of from.all) { + const key = `${f}\u0000${t.label}`; + if (t.label && handled.has(key)) continue; + for (const to of lookup(t.to).initial) out.transitions.push({ from: f, to, label: t.label }); + if (t.label) pending.push(key); + } + } + return out; + } + + const regions = {}; + const top = root.parts[0]; + const machine = top.children.filter((c) => c.name !== LEGEND_ID); + const parallel = machine.length === 1 && !machine[0].leaf && machine[0].parts.length > 1 && top.transitions.every((t) => t.from === "[*]"); + if (parallel) { + for (const p of machine[0].parts) { + const [only] = p.children; + const holder = p.children.length === 1 && !only.leaf && p.transitions.every((t) => t.from === "[*]" && t.to === only.name) ? only : null; + if (holder && holder.parts.length > 1) throw new Error(`${display(holder.name)} splits into regions below the top level`); + regions[holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`] = region(holder ? holder.parts[0] : p); + } + } else regions.Main = region(top); + return { regions, nevers }; +} + +export const STATUS_CLASSES = [ + "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", + "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", + "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", + "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", + "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", +]; diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs new file mode 100644 index 000000000..d73cc9da9 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -0,0 +1,176 @@ +// node --test scripts/test/statechart.test.mjs +// Set TLA2TOOLS (path to tla2tools.jar) and JAVA (java binary, default "java") to run the TLC tests too. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const scripts = dirname(here); +const fixture = (name) => readFileSync(join(here, name), "utf8"); +const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); +const node = (script, args, cwd) => spawnSync(process.execPath, [join(scripts, script), ...args], { cwd, encoding: "utf8" }); + +test("a transition on a composite leaves from every state inside it", () => { + const { regions } = parseMermaid(fixture("upload.mmd")); + const main = regions.Main; + assert.deepEqual([...main.states].sort(), ["Nothing chosen", "Upload failed", "Uploaded", "Uploading.Checking the file", "Uploading.Sending the file"]); + assert.deepEqual([...main.initial], ["Nothing chosen"]); + assert.deepEqual(edges(main), [ + "Nothing chosen -> Uploading.Sending the file : you choose a file", + "Upload failed -> Uploading.Sending the file : you retry", + "Uploading.Checking the file -> Nothing chosen : you cancel", + "Uploading.Checking the file -> Upload failed : the network drops", + "Uploading.Checking the file -> Uploaded : the check passes", + "Uploading.Sending the file -> Nothing chosen : you cancel", + "Uploading.Sending the file -> Upload failed : the network drops", + "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", + ]); +}); + +test("the deepest source wins when a composite and a state inside it share a label", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state Form { + [*] --> Typing + Typing --> Confirm : you press Escape + state Confirm + } + [*] --> Form + Form --> Closed : you press Escape + Confirm --> Closed : you press Escape`); + assert.deepEqual(edges(regions.Main), ["Form.Confirm -> Closed : you press Escape", "Form.Typing -> Form.Confirm : you press Escape"]); +}); + +test("a top-level composite split by -- makes one region per part", () => { + const { regions, nevers } = parseMermaid(fixture("sync.mmd")); + assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); + assert.deepEqual(edges(regions["Sync badge"]), [ + "Synced -> Waiting to sync : the network drops", + "Synced -> Waiting to sync : you edit a note", + "Waiting to sync -> Synced : the server confirms", + ]); + assert.deepEqual(nevers, [{ name: "NeverSyncedWhileOffline", states: ["Offline", "Synced"], sentence: 'You never see "Synced" while you are offline.' }]); +}); + +test("the legend is not part of the machine", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state "Legend" as legend { + state "Works today" as legendWorks + } + [*] --> A + A --> B : go`); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); +}); + +test("the parser refuses charts it cannot read", () => { + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); + assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), /no \[\*\] --> start/); + assert.throws( + () => parseMermaid("stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A"), + /below the top level/, + ); +}); + +test("TLC values, states, graphs, and traces parse", () => { + assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); + assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); + const graph = parseDot(`strict digraph DiskGraph { +1 [label="/\\\\ x = 0",style = filled] +2 [label="/\\\\ x = 1"] +1 -> 2 [label="Inc",color="black",fontcolor="black"]; +2 -> 2 [label="Stay"]; +}`); + assert.equal(graph.nodes.get("1").init, true); + assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); + assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + const [trace] = parseTraces(`Error: Invariant Small is violated. +Error: The behavior up to this point is: +State 1: +x = 0 + +State 2: +x = 1 +`); + assert.equal(trace.invariant, "Small"); + assert.deepEqual(trace.steps, [{ action: null, vars: { x: 0 } }, { action: "Inc", vars: { x: 1 } }]); +}); + +test("chart-to-tla writes one action per label and one invariant per never line", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + assert.match(spec, /^VARIABLES connection, syncBadge$/m); + assert.match(spec, /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m); + assert.match(spec, /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m); + assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n"); +}); + +test("chart-to-tla refuses a never line that names no state", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "c.mmd"), "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n"); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /names "C", which is not a state/); +}); + +const jar = process.env.TLA2TOOLS; +const java = process.env.JAVA ?? "java"; +const needsTlc = { skip: jar ? false : "set TLA2TOOLS to run TLC" }; + +function tlc(dir, name) { + const r = spawnSync( + java, + ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", "-dump", "dot,actionlabels", `${name}.dot`, "-config", `${name}.cfg`, `${name}.tla`], + { cwd: dir, encoding: "utf8" }, + ); + writeFileSync(join(dir, `${name}.out`), r.stdout); + return r; +} + +test("chart to TLA+ to chart gives back the same chart", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); + assert.equal(node("chart-to-tla.mjs", ["upload.mmd", "--module", "Upload"], dir).status, 0); + assert.match(tlc(dir, "Upload").stdout, /Model checking completed\. No error has been found\./); + const drawn = node("tlc-to-chart.mjs", ["--dot", "Upload.dot", "--map", "Upload.map.mjs", "--highlight", "0", "--out", "again.mmd"], dir); + assert.equal(drawn.status, 0, drawn.stderr); + const same = node("compare-charts.mjs", ["upload.mmd", "again.mmd"], dir); + assert.equal(same.stdout.trim(), "same"); +}); + +test("a never line finds the design bug, and the fixed model marks it fixed", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + assert.equal(node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir).status, 0); + // With -continue, TLC exits 0 even when it finds a violation. + assert.match(tlc(dir, "Sync").stdout, /Invariant NeverSyncedWhileOffline is violated/); + + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + const guarded = spec.replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n').replace("MODULE Sync", "MODULE SyncFixed"); + assert.notEqual(guarded, spec); + writeFileSync(join(dir, "SyncFixed.tla"), guarded); + writeFileSync(join(dir, "SyncFixed.cfg"), readFileSync(join(dir, "Sync.cfg"))); + assert.doesNotMatch(tlc(dir, "SyncFixed").stdout, /is violated/); + + const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", "Sync.out", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + const before = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(before.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 2]]); + assert.equal(before.chartStatus.wrong, 1); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /1\. the network drops\. Now: Offline \| Waiting to sync\n2\. the server confirms\. Now: Offline \| Synced/); + assert.match(readFileSync(join(dir, "chart.mmd"), "utf8"), /: 2\. the server confirms\n/); + + const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", "SyncFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const after = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(after.chartStatus, { works: 3, wrong: 0, fixed: 1 }); + assert.equal(after.classes[0].fix, "Offline | Waiting to sync"); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /`TheServerConfirms`\) cannot happen/); +}); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/sync.mmd b/harness/pstack/claude-code/skills/statechart/scripts/test/sync.mmd new file mode 100644 index 000000000..c780c2e43 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/sync.mmd @@ -0,0 +1,19 @@ +stateDiagram-v2 + state "Notes app" as App { + state "Connection" as Connection { + [*] --> Online + Online --> Offline : the network drops + Offline --> Online : the network returns + } + -- + state "Sync badge" as Sync { + state "Synced" as Synced + state "Waiting to sync" as Pending + [*] --> Synced + Synced --> Pending : you edit a note + Synced --> Pending : the network drops + Pending --> Synced : the server confirms + } + } + [*] --> App + %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/upload.mmd b/harness/pstack/claude-code/skills/statechart/scripts/test/upload.mmd new file mode 100644 index 000000000..dcfafa737 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/upload.mmd @@ -0,0 +1,16 @@ +stateDiagram-v2 + state "Nothing chosen" as Idle + state "Uploading" as Uploading { + state "Sending the file" as Sending + state "Checking the file" as Checking + [*] --> Sending + Sending --> Checking : the last byte arrives + } + state "Uploaded" as Done + state "Upload failed" as Failed + [*] --> Idle + Idle --> Uploading : you choose a file + Checking --> Done : the check passes + Uploading --> Failed : the network drops + Failed --> Uploading : you retry + Uploading --> Idle : you cancel diff --git a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs new file mode 100644 index 000000000..8cd315a90 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +// Collapse a TLC state graph onto the reader's states and write a Mermaid stateDiagram-v2. +// +// node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [options] +// +// --dot FILE state graph from TLC -dump dot,actionlabels FILE (the current code) +// --map FILE mapping module: state(vars, facts), optional detail(vars, facts), +// step(action, before, after), cause(vars, facts, invariant) (the hidden reason a bad state is bad; +// counterexamples with the same invariant and cause are one bug), and +// readerActions (the actions the reader takes). +// facts.enabled is the set of actions the state enables; facts.settled is true when +// only reader actions are enabled, so nothing else happens until the reader acts. +// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state) +// --fixed-dot FILE state graph of the model with the open fix; counterexamples replay against it +// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still wrong +// --view NAME customer (state labels), developer (detail labels), or layered (detail inside state) +// --cover keep only the shortest path to each settled reader state, plus counterexample paths +// --highlight N draw counterexample class N as a numbered path (default 1; 0 for none) +// --path-only draw only the highlighted path +// --title TEXT chart title +// --out FILE Mermaid output (default stdout) +// --story FILE reader stories, one per counterexample class +// --summary FILE JSON counts for the chart and the classes +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { LEGEND_ID, parseDot, parseTraces, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; + +const args = { traces: [], fixedTraces: [], view: "customer", highlight: 1 }; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + const next = () => process.argv[++i]; + if (a === "--dot") args.dot = next(); + else if (a === "--map") args.map = next(); + else if (a === "--traces") args.traces.push(next()); + else if (a === "--fixed-dot") args.fixedDot = next(); + else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--view") args.view = next(); + else if (a === "--highlight") args.highlight = Number(next()); + else if (a === "--path-only") args.pathOnly = true; + else if (a === "--cover") args.cover = true; + else if (a === "--title") args.title = next(); + else if (a === "--out") args.out = next(); + else if (a === "--story") args.story = next(); + else if (a === "--summary") args.summary = next(); + else { + console.error(`unknown argument ${a}`); + process.exit(2); + } +} +if (!args.dot || !args.map) { + console.error("Usage: node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [--traces tlc.out] [--fixed-dot fixed.dot] [--view customer|developer|layered]"); + process.exit(2); +} +if (!["customer", "developer", "layered"].includes(args.view)) { + console.error(`--view must be customer, developer, or layered, not ${args.view}`); + process.exit(2); +} + +const mapping = await import(pathToFileURL(path.resolve(args.map)).href); +if (typeof mapping.state !== "function") throw new Error("the mapping module must export state(vars)"); +if (args.view !== "customer" && typeof mapping.detail !== "function") throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); +const SEP = "\u0000"; + +const readerActions = new Set(mapping.readerActions ?? []); +const factsFrom = (enabled) => ({ enabled, settled: [...enabled].every((a) => readerActions.has(a)) }); +let factsOf = () => factsFrom(new Set()); +function keyOf(vars) { + const facts = factsOf(vars); + if (args.view === "developer") return String(mapping.detail(vars, facts)); + const outer = mapping.state(vars, facts); + if (typeof outer !== "string") throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); + return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; +} +function stepLabel(action, before, after) { + const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; + return args.view === "customer" || said === action ? said : `${said} [${action}]`; +} + +function quotient(graph) { + const keys = new Map(); + for (const n of graph.nodes.values()) keys.set(n.id, keyOf(n.vars)); + const nodes = new Set(keys.values()); + const initial = new Set([...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id))); + const edges = new Map(); + for (const e of graph.edges) { + const from = keys.get(e.from); + const to = keys.get(e.to); + if (from === to) continue; + const id = `${from}${SEP}${SEP}${to}`; + if (!edges.has(id)) edges.set(id, { from, to, counts: new Map() }); + const label = stepLabel(e.action, graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars); + if (label) edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); + } + // Most common wording first. A chart edge shows two at most. + for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); + const settled = new Set([...graph.nodes.values()].filter((n) => factsOf(n.vars).settled).map((n) => keys.get(n.id))); + return { nodes, initial, edges, keys, settled }; +} + +function enabledIndex(g) { + const byId = new Map([...g.nodes.keys()].map((id) => [id, new Set()])); + for (const e of g.edges) byId.get(e.from).add(e.action); + return new Map([...g.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); +} +function useFacts(index) { + factsOf = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set()); +} + +const graph = parseDot(fs.readFileSync(args.dot, "utf8")); +const graphIndex = enabledIndex(graph); +useFacts(graphIndex); +const now = quotient(graph); +const fixedGraph = args.fixedDot ? parseDot(fs.readFileSync(args.fixedDot, "utf8")) : null; +const fixedIndex = fixedGraph ? enabledIndex(fixedGraph) : null; +let fixed = null; +if (fixedGraph) { + useFacts(fixedIndex); + fixed = quotient(fixedGraph); + useFacts(graphIndex); +} + +const customerKey = (vars) => String(mapping.state(vars, factsOf(vars))); + +// Reader path of one trace: drop system steps that keep the reader in the same state. +// Reader actions stay even then, because the reader did them. +function readerPath(trace, key0 = keyOf) { + const out = [{ key: key0(trace.steps[0].vars), label: null, modelStep: 0 }]; + for (let i = 1; i < trace.steps.length; i++) { + const key = key0(trace.steps[i].vars); + if (key === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; + const label = stepLabel(trace.steps[i].action, trace.steps[i - 1].vars, trace.steps[i].vars) || trace.steps[i].action; + out.push({ key, label, modelStep: i }); + } + return out; +} + +// Replay a trace's actions in the fixed graph. Returns where the fixed model leaves the trace. +function replayFixed(trace) { + if (!fixedGraph) return null; + const byKey = new Map([...fixedGraph.nodes.values()].map((n) => [stateKey(n.vars), n])); + const out = new Map(); + for (const e of fixedGraph.edges) { + if (!out.has(e.from)) out.set(e.from, []); + out.get(e.from).push(e); + } + let cur = byKey.get(stateKey(trace.steps[0].vars)); + if (!cur) return { step: 0, reason: "the fixed model has no matching initial state" }; + for (let i = 1; i < trace.steps.length; i++) { + const want = trace.steps[i]; + const same = (out.get(cur.id) ?? []).filter((e) => e.action === want.action); + const exact = same.find((e) => stateKey(fixedGraph.nodes.get(e.to).vars) === stateKey(want.vars)); + if (exact) { + cur = fixedGraph.nodes.get(exact.to); + continue; + } + const score = (vars) => Object.keys(want.vars).filter((k) => JSON.stringify(vars[k]) === JSON.stringify(want.vars[k])).length; + const best = same.map((e) => fixedGraph.nodes.get(e.to)).sort((a, b) => score(b.vars) - score(a.vars))[0]; + const reached = best ?? cur; + useFacts(fixedIndex); + const key = keyOf(reached.vars); + useFacts(graphIndex); + return { step: i, action: want.action, disabled: !best, key }; + } + return { step: trace.steps.length, sameEnd: true }; +} + +// With -continue, TLC reports every bad state, including states after the first bad one. +// Cut each trace at its first bad state so one bug counts once. +function firstBad(files) { + const raw = files.flatMap((f) => parseTraces(fs.readFileSync(f, "utf8"))); + const badStates = new Map(); + for (const t of raw) { + if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); + badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); + } + const cuts = []; + const seen = new Set(); + for (const t of raw) { + const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); + const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; + const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; + if (seen.has(id)) continue; + seen.add(id); + cuts.push(cut); + } + return cuts; +} +const traces = firstBad(args.traces); +const fixedTraces = firstBad(args.fixedTraces); +// Classes and their shortest traces come from the customer states in every view, +// so class N is the same bug and the same trace in the customer and developer charts. +const classesById = new Map(); +for (const trace of traces) { + const rp = readerPath(trace, customerKey); + const last = trace.steps[trace.steps.length - 1].vars; + const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = classesById.get(id); + if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { + classesById.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); + } else prev.count++; +} +const classes = [...classesById.values()].sort( + (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), +); +for (const c of classes) { + c.path = readerPath(c.trace); + c.end = c.path[c.path.length - 1].key; + c.fix = replayFixed(c.trace); +} + +// ---------- status ---------- +// A chart state is still wrong when the fixed model's checks still find a first bad state in it, +// and fixed when only the current model's checks do. Without --fixed-traces every bad state is wrong. +const isFixed = (c) => c.fix?.key !== undefined && c.fix.key !== c.end; +const endKeys = (list) => new Set(list.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); +const badNow = endKeys(traces); +useFacts(fixedIndex ?? graphIndex); +const badFixed = args.fixedTraces.length > 0 ? endKeys(fixedTraces) : badNow; +useFacts(graphIndex); +const allNodes = new Set([...now.nodes, ...(fixed?.nodes ?? [])]); +const status = (k) => (badFixed.has(k) ? "wrong" : badNow.has(k) || (fixed && !now.nodes.has(k)) ? "fixed" : "works"); + +const edges = new Map(now.edges); +if (fixed) { + for (const [id, e] of fixed.edges) { + if (!edges.has(id)) edges.set(id, { ...e, labels: new Set([...e.labels].map((l) => `${l} (with the fix)`)), fixedOnly: true }); + } +} +for (const c of classes) { + if (isFixed(c)) { + const id = `${c.end}${SEP}${SEP}${c.fix.key}`; + if (!edges.has(id)) edges.set(id, { from: c.end, to: c.fix.key, labels: new Set(["with the fix"]), fixedOnly: true }); + } +} + +// ---------- highlighted path ---------- +const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; +if (args.highlight > 0 && classes.length > 0 && !chosen) throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); +const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); +const pathNumbers = new Map(); +if (chosen) { + chosen.path.slice(1).forEach((p, i) => { + const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; + if (!pathNumbers.has(id)) pathNumbers.set(id, { n: [], label: p.label }); + pathNumbers.get(id).n.push(i + 1); + if (!edges.has(id)) edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); + }); + if (isFixed(chosen)) onPath.add(chosen.fix.key); +} + +// ---------- cover: shortest path to each settled reader state ---------- +let coverEdges = null; +if (args.cover) { + const keepEdges = new Set(); + const bfs = (q, targets) => { + const parent = new Map([...q.initial].map((k) => [k, null])); + const queue = [...q.initial]; + const outEdges = new Map(); + for (const [id, e] of q.edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from).push([id, e]); + } + while (queue.length) { + const k = queue.shift(); + for (const [id, e] of outEdges.get(k) ?? []) { + if (parent.has(e.to)) continue; + parent.set(e.to, id); + queue.push(e.to); + } + } + for (const t of targets) { + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); + } + }; + bfs(now, now.settled); + if (fixed) bfs(fixed, [...fixed.settled].filter((k) => !now.nodes.has(k))); + for (const c of classes) { + c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); + if (isFixed(c)) keepEdges.add(`${c.end}${SEP}${SEP}${c.fix.key}`); + } + for (const id of keepEdges) { + if (!edges.has(id)) { + const [from, to] = id.split(`${SEP}${SEP}`); + const c = classes.find((x) => x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to)); + const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); + edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); + } + } + coverEdges = keepEdges; +} + +// ---------- Mermaid ---------- +let keep = allNodes; +if (args.pathOnly && chosen) keep = onPath; +else if (coverEdges) { + keep = new Set([...now.initial, ...(fixed?.initial ?? [])]); + for (const id of coverEdges) for (const k of id.split(`${SEP}${SEP}`)) keep.add(k); +} +const ids = new Map(); +let counter = 0; +const idOf = (k) => { + if (!ids.has(k)) ids.set(k, `s${counter++}`); + return ids.get(k); +}; +const label = (k) => (args.view === "layered" ? k.split(SEP)[1] : k); +const out = []; +if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); +out.push("stateDiagram-v2"); +for (const c of STATUS_CLASSES) out.push(` ${c}`); +out.push(` state "Legend" as ${LEGEND_ID} {`); +out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed in an open PR" as legendFixed'); +out.push(" }", " class legendWorks works", " class legendWrong wrong", " class legendFixed fixed"); +const sorted = [...keep].sort(); +if (args.view === "layered") { + const groups = new Map(); + for (const k of sorted) { + const outer = k.split(SEP)[0]; + if (!groups.has(outer)) groups.set(outer, []); + groups.get(outer).push(k); + } + let g = 0; + for (const [outer, members] of groups) { + out.push(` state "${wrap(outer)}" as g${g++} {`); + for (const k of members) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); + out.push(" }"); + } +} else { + for (const k of sorted) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); +} +const initial = new Set([...now.initial, ...(fixed?.initial ?? [])]); +for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); +let edgeCount = 0; +for (const [id, e] of edges) { + if (!keep.has(e.from) || !keep.has(e.to)) continue; + if (args.pathOnly && !pathNumbers.has(id) && !e.labels.has("with the fix")) continue; + if (coverEdges && !coverEdges.has(id)) continue; + const all = [...e.labels]; + let text = all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + const p = pathNumbers.get(id); + if (p) text = `${p.n.join(", ")}. ${p.label}`; + // ";" ends a Mermaid statement, even inside a transition label. + text = text.replaceAll(";", ","); + out.push(` ${idOf(e.from)} --> ${idOf(e.to)}${text ? ` : ${wrap(text, 30)}` : ""}`); + edgeCount++; +} +for (const k of sorted) out.push(` class ${idOf(k)} ${status(k)}${onPath.has(k) ? "Path" : ""}`); +const mermaid = out.join("\n") + "\n"; +if (args.out) fs.writeFileSync(args.out, mermaid); +else process.stdout.write(mermaid); + +// ---------- stories and summary ---------- +const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); +if (args.story) { + const lines = []; + classes.forEach((c, i) => { + lines.push(`## ${i + 1}. ${show(c.end)}`, ""); + lines.push(`Breaks \`${c.invariant ?? "unknown"}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); + c.path.forEach((p, j) => lines.push(j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${p.label}. Now: ${show(p.key)}`)); + lines.push(""); + if (!c.fix) lines.push("No fixed model given."); + else if (c.fix.sameEnd) lines.push("The fixed model follows the whole trace: the fix does not change this path."); + else if (c.fix.reason) lines.push(`The fixed model cannot replay this trace: ${c.fix.reason}.`); + else lines.push(`With the fix, model step ${c.fix.step} (\`${c.fix.action}\`) ${c.fix.disabled ? "cannot happen" : "goes elsewhere"}, and you see: ${show(c.fix.key)}`); + lines.push(""); + }); + fs.writeFileSync(args.story, lines.join("\n")); +} +const summary = { + view: args.view, + modelStates: graph.nodes.size, + modelTransitions: graph.edges.length, + chartStates: keep.size, + chartTransitions: edgeCount, + chartStatus: Object.fromEntries(["works", "wrong", "fixed"].map((s) => [s, [...keep].filter((k) => status(k) === s).length])), + classes: classes.map((c, i) => ({ + n: i + 1, + end: show(c.end), + invariant: c.invariant, + cause: c.cause, + readerSteps: c.path.length - 1, + modelSteps: c.trace.steps.length, + counterexamples: c.count, + fix: c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, + })), +}; +if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); +console.error(`chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ${classes.length} counterexample class(es)`); diff --git a/pstack/.cursor-plugin/plugin.json b/pstack/.cursor-plugin/plugin.json index fe3321bdc..a38e69da5 100644 --- a/pstack/.cursor-plugin/plugin.json +++ b/pstack/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "pstack", "displayName": "pstack", - "version": "0.15.29", + "version": "0.15.30", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", "author": { "name": "Lauren Tan" diff --git a/pstack/README.md b/pstack/README.md index 9fde3a06f..f7cfadd6a 100644 --- a/pstack/README.md +++ b/pstack/README.md @@ -151,6 +151,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it | [`/maintain-verification-skill`](./skills/maintain-verification-skill/SKILL.md) | your verify skill's feature map has drifted from the app. source wave + one live pass, at most one PR of proven corrections. | | [`/create-formal-verification-skill`](./skills/create-formal-verification-skill/SKILL.md) | tests can't reach the case that breaks a race, retry, state machine, parser, or unsafe code. verifies one property with the tool that fits and generates a project-local skill to recheck it on every change. | | [`/maintain-formal-verification-skill`](./skills/maintain-formal-verification-skill/SKILL.md) | code your formal verification covers has changed since it was last checked. updates the model, harness, or properties to match the code, reruns, and says whether each failure is drift in the verification or a bug in the code. | +| [`/statechart`](./skills/statechart/SKILL.md) | someone who owns the behavior needs to see it, not read a spec. draws what the user sees as a mermaid statechart generated from a tla+ model, marks each state works, still wrong, or fixed by your open pr, and draws each bug tlc finds as a numbered path. also turns an agreed chart into a tla+ skeleton. | | [`/unslop`](./skills/unslop/SKILL.md) | you're cleaning up writing. removes AI tells. | | [`/bro`](./skills/bro/SKILL.md) | you want the last message restated in plain human language, no jargon. | | [`/technical-writing`](./skills/technical-writing/SKILL.md) | layered doc standard (Diátaxis + Google developer style + STE + Global English) for docs, RFCs, readmes, PR descriptions, commit messages. | diff --git a/pstack/docs/guide/06-verify-and-ship.md b/pstack/docs/guide/06-verify-and-ship.md index 3c7b3c4dd..83735d7e5 100644 --- a/pstack/docs/guide/06-verify-and-ship.md +++ b/pstack/docs/guide/06-verify-and-ship.md @@ -115,6 +115,18 @@ It writes a project-local skill that tells the next agent whether a change touch The skill also records which code each check covers and the commit where the check last matched it. Merges and refactors that skip the skill can leave the check passing on a description of code that's gone. After them, run [`/maintain-formal-verification-skill`](../../skills/maintain-formal-verification-skill/SKILL.md). It updates the verification from the code, reruns it, shows that each updated check can still fail, and tells you whether each failure is drift in the verification or a bug in the code. +## Show the behavior as a statechart + +A counterexample in model terms convinces you. The PM or designer who owns the behavior needs to see it in their terms. Run: + +```text +/statechart what the user sees when they edit a note offline +``` + +[`/statechart`](../../skills/statechart/SKILL.md) draws a Mermaid statechart of what the user sees, generated from a TLA+ model, so the picture and the checked model can't drift apart. The reader's rules ("you never see Synced while you're offline") become invariants. Each bug TLC finds is a numbered path in the reader's words, and each state is marked works, still wrong, or fixed by your open PR. Before the hand-off, the agent reproduces each bug path in the real code, so a modeling error doesn't reach the reader as a bug. + +It also runs the other way. Sketch the behavior as a chart, agree on it with the reader, and the skill writes a TLA+ skeleton from it. TLC checks the agreed chart's rules before any code exists. As guards from the real code go into the spec, each place the code departs from the chart shows up as a difference for the reader to decide. + ## Open the PR ```text diff --git a/pstack/plugin.json b/pstack/plugin.json index 2b28f689c..4c3918502 100644 --- a/pstack/plugin.json +++ b/pstack/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "pstack", - "version": "0.15.29", + "version": "0.15.30", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.", "author": { "name": "Lauren Tan" diff --git a/pstack/skills/poteto-help/SKILL.md b/pstack/skills/poteto-help/SKILL.md index 3eb02a5bf..a54f117be 100644 --- a/pstack/skills/poteto-help/SKILL.md +++ b/pstack/skills/poteto-help/SKILL.md @@ -88,6 +88,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste | Bring a verification skill and its feature map back in line with the app | [`/maintain-verification-skill`](../maintain-verification-skill/SKILL.md) | | Formally verify a race, invariant, or other property that tests rarely reach | [`/create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) | | Bring a formal verification back in line after the code it covers changed, and learn whether a failure is drift or a bug | [`/maintain-formal-verification-skill`](../maintain-formal-verification-skill/SKILL.md) | +| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, or turn an agreed chart into a TLA+ skeleton | [`/statechart`](../statechart/SKILL.md) | | Vet a performance number before reporting or acting on it | [`/benchmark-checklist`](../benchmark-checklist/SKILL.md) | | Run a large or cross-cutting change, or one to review after stepping away | [`/figure-it-out`](../figure-it-out/SKILL.md) | | Keep a decision log during a run, and review it afterward | [`/show-me-your-work`](../show-me-your-work/SKILL.md) | diff --git a/pstack/skills/statechart/SKILL.md b/pstack/skills/statechart/SKILL.md new file mode 100644 index 000000000..56a8c7491 --- /dev/null +++ b/pstack/skills/statechart/SKILL.md @@ -0,0 +1,115 @@ +--- +name: statechart +description: "Draw what a user sees as a Mermaid statechart generated from a TLA+ model. States are marked works, still wrong, or fixed, and each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." +disable-model-invocation: true +--- + +# Statechart + +A PM, a designer, or a reviewer can judge a chart of what they see. They cannot judge a TLA+ spec. This skill draws the chart from the model, so the chart and the checked model are one object. The reader's rules ("you never see X while Y") become invariants. TLC finds every path that breaks one. The chart shows each as a numbered path in the reader's words. + +## Pick the direction + +- **A model exists, or the code is concrete enough to model.** Go model to chart. If the project has a formal verification skill, start from its spec. A spec written for one property may not hold what the reader sees. Add those variables and keep its old invariants passing. A new spec follows the "true to the code" rules in [create-formal-verification-skill](../create-formal-verification-skill/SKILL.md). +- **No model, and the reader should agree on the behavior before code exists or changes.** Go chart to model. + +## Setup + +- Java 11 or later and `tla2tools.jar` from the [TLA+ releases](https://github.com/tlaplus/tlaplus/releases). Set `TLA2TOOLS` to the jar's path. +- Node. The scripts in `scripts/` next to this file have no dependencies. +- To render, `npx -y @mermaid-js/mermaid-cli -i chart.mmd -o chart.png -s 2`. + +Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. With `-continue`, TLC keeps going past a broken rule and exits 0, so read its output, not its exit code. + +```bash +tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -continue "$@"; } +``` + +## Model to chart + +1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. + +2. **Write the reader's rules as invariants.** One sentence the reader agrees to is one invariant, with the sentence as its comment. For "once everything settles", define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the reader does not take. Never weaken a rule to make the chart pass. A broken rule is the finding. + +3. **Write the mapping module.** It is an ES module that `tlc-to-chart.mjs` imports. + + ```js + export const readerActions = ["EditNote", "OpenApp"]; + // Reader words for one model state. facts.settled is true when only reader actions can happen. + export function state(v, facts) {} + // Edge label in reader words. Return "" to hide a step. + export function step(action, before, after) {} + // Why a bad state is bad, in code terms. + export function cause(v, facts, invariant) {} + // Developer words for one model state. Needed only for --view developer or layered. + export function detail(v, facts) {} + ``` + + Counterexamples that break the same rule with the same `cause` are one bug. Two bugs that end on the same screen through different code paths stay apart only if `cause` names the code path, such as "the badge read the cache before the server answered". + +4. **Run TLC.** One run dumps the state graph. Then run once per rule, because TLC reports only the first rule a state breaks. + + ```bash + tlc -dump dot,actionlabels Spec.dot -config Spec.cfg Spec.tla > Spec.out + for rule in NoStaleBadge NoLostEdit; do + { grep -v '^INVARIANT' Spec.cfg; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" + tlc -config "Spec-$rule.cfg" Spec.tla > "Spec-$rule.out" + echo "$rule: $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" + done + ``` + + If an open change fixes some of it, model the change as a second spec (`SpecFixed.tla`) and run the same commands on it. + +5. **Draw the reader's chart.** + + ```bash + node scripts/tlc-to-chart.mjs --dot Spec.dot --map map.mjs \ + --traces Spec-NoStaleBadge.out --traces Spec-NoLostEdit.out \ + --fixed-dot SpecFixed.dot \ + --fixed-traces SpecFixed-NoStaleBadge.out --fixed-traces SpecFixed-NoLostEdit.out \ + --cover --highlight 1 --title "Offline editing today" \ + --out chart.mmd --story stories.md --summary summary.json + ``` + + Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Draw one chart per bug for the hand-off, and one with `--highlight 0` for the overview. + + Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and redraw. + +6. **Show one bug's mechanism.** `--view developer --path-only --highlight N` draws bug N through the `detail` states. Do not draw a whole developer chart. On one model it had twelve times the states of the reader's chart. `--view layered` puts the developer states inside the reader states, for a reviewer who wants both on one page. + +7. **Reproduce every red path in the real code.** The model can be wrong. Replay each bug's story from `stories.md` as a test or a live repro against the real code, one step per model action. A bug you cannot reproduce is a modeling error until shown otherwise. Fix the model, not the rule. Report which bugs reproduced. + +## Chart to model + +1. **Write the chart** in Mermaid `stateDiagram-v2`. Split a top-level composite with `--` into regions. Each region is one variable. Each transition label is one action, and regions with the same label move together. Write each rule as a comment line. + + ``` + %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. + ``` + + Each name in a `never` line is a state, or a composite that stands for every state inside it. The names must come from different regions. The chart cannot hold history states. Draw "comes back to where it was" as an edge to that state. + +2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`. + +3. **Check the agreed chart.** Run step 4 of model to chart on `Sync`, then draw and compare. + + ```bash + node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out --highlight 0 --out drawn.mmd + node scripts/compare-charts.mjs chart.mmd drawn.mmd + ``` + + `compare-charts.mjs` prints `same` when the drawn chart has the same states and transitions. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. + +4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. + +## Read the chart + +- Gray states work today. Red states are still wrong, because the checks still reach a first bad state there with the fix in. Green states are fixed by the open change. Without `--fixed-*`, every bad state is red. +- A thick blue border and numbered labels mark the highlighted bug. Mermaid state diagrams cannot color an edge. +- An edge shows its two most common labels. "or …" means there are more. +- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. +- `summary.json` has the counts behind the chart. Quote it, not an estimate. + +## Hand off + +Give the reader the PNG of each chart and its Mermaid source. Add one line per bug with the rule it breaks, the reader's steps, whether it reproduced, and whether the open change fixes it. Attach `stories.md` and the commands that regenerate everything. Say what the model leaves out. diff --git a/pstack/skills/statechart/scripts/chart-to-tla.mjs b/pstack/skills/statechart/scripts/chart-to-tla.mjs new file mode 100644 index 000000000..2721c88b5 --- /dev/null +++ b/pstack/skills/statechart/scripts/chart-to-tla.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +// Write a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. +// +// node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT] +// +// Each region becomes one variable whose values are the region's states. +// Each transition label becomes one action. Regions that share a label move together: +// every region that can take it does, the others stay. An unlabeled transition is its own action. +// Each "%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\ B). +// A name in a never line is a state, or a composite that stands for every state inside it. +// +// Writes NAME.tla, NAME.cfg, and NAME.map.mjs (for tlc-to-chart.mjs) into OUT, next to the chart by default. +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseMermaid } from "./lib.mjs"; + +const args = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (a === "--module") args.module = process.argv[++i]; + else if (a === "--dir") args.dir = process.argv[++i]; + else if (!a.startsWith("--") && !args.chart) args.chart = a; + else { + console.error(`unknown argument ${a}`); + process.exit(2); + } +} +if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) { + console.error("Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"); + process.exit(2); +} +const dir = args.dir ?? path.dirname(args.chart); +const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); + +const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]); +const taken = new Set(RESERVED); +function ident(words, lower) { + const parts = words.normalize("NFKD").replace(/[^A-Za-z0-9]+/g, " ").trim().split(" ").filter(Boolean); + let id = parts.map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)).join("") || "Step"; + if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; + let unique = id; + for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; + taken.add(unique); + return unique; +} +const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +const set = (xs) => `{${[...xs].map(str).join(", ")}}`; + +const vars = Object.entries(regions).map(([name, r]) => { + if (r.states.size === 0) throw new Error(`region ${name} has no states`); + if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); + return { name, v: ident(name, true), ...r }; +}); + +// One action per label. Unlabeled transitions get one action each. +const actions = []; +const byLabel = new Map(); +for (const [ri, r] of vars.entries()) { + for (const t of r.transitions) { + let a = t.label ? byLabel.get(t.label) : null; + if (!a) { + a = { label: t.label, moves: new Map() }; + actions.push(a); + if (t.label) byLabel.set(t.label, a); + } + if (!a.moves.has(ri)) a.moves.set(ri, []); + const moves = a.moves.get(ri); + if (!moves.some((m) => m.from === t.from && m.to === t.to)) moves.push({ from: t.from, to: t.to }); + } +} +for (const a of actions) { + const only = a.label ? null : [...a.moves.values()][0][0]; + a.name = ident(a.label || `${only.from} to ${only.to}`, false); +} + +const out = []; +const bullet = (indent, op, lines) => lines.map((l, i) => `${" ".repeat(indent)}${op} ${l}`); +out.push(`---- MODULE ${args.module} ----`); +out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); +out.push("\\* Each region is one variable and each transition label is one action."); +out.push("\\* Once you add guards and variables from the real code, this file is the source:"); +out.push("\\* draw the chart from it with tlc-to-chart.mjs and check it with compare-charts.mjs."); +out.push(""); +out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`); +out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`); +out.push(""); +for (const x of vars) out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); +out.push(""); +out.push("TypeOK ==", ...bullet(4, "/\\", vars.map((x) => `${x.v} \\in ${x.v}States`))); +out.push(""); +out.push("Init ==", ...bullet(4, "/\\", vars.map((x) => (x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`)))); +for (const a of actions) { + out.push(""); + if (a.label) out.push(`\\* ${a.label}`); + out.push(`${a.name} ==`); + const moving = [...a.moves.keys()]; + const conj = []; + if (moving.length > 1) conj.push(`(${moving.map((ri) => `${vars[ri].v} \\in ${set(new Set(a.moves.get(ri).map((m) => m.from)))}`).join(" \\/ ")})`); + const blocks = []; + for (const ri of moving) { + const v = vars[ri].v; + const ds = a.moves.get(ri).map((m) => `(${v} = ${str(m.from)} /\\ ${v}' = ${str(m.to)})`); + if (moving.length > 1) ds.push(`(${v} \\notin ${set(new Set(a.moves.get(ri).map((m) => m.from)))} /\\ UNCHANGED ${v})`); + blocks.push(ds); + } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); + for (const c of conj) out.push(` /\\ ${c}`); + for (const ds of blocks) { + out.push(` /\\ \\/ ${ds[0]}`); + for (const d of ds.slice(1)) out.push(` \\/ ${d}`); + } + if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); +} +out.push(""); +out.push(actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars"); +out.push(""); +out.push("Spec == Init /\\ [][Next]_vars"); + +function resolve(item) { + const hits = []; + for (const [ri, x] of vars.entries()) { + const inside = [...x.states].filter((s) => s === item || s.startsWith(`${item}.`)); + if (inside.length) hits.push({ ri, inside }); + } + if (hits.length === 0) throw new Error(`never line names "${item}", which is not a state in the chart`); + if (hits.length > 1) throw new Error(`never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`); + return hits[0]; +} +const invariants = []; +for (const n of nevers) { + if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); + taken.add(n.name); + const parts = n.states.map(resolve); + const regionsUsed = new Set(parts.map((p) => p.ri)); + if (regionsUsed.size !== parts.length) throw new Error(`never ${n.name} names two states of one region; one region is in one state at a time`); + const cond = parts.map((p) => (p.inside.length === 1 ? `${vars[p.ri].v} = ${str(p.inside[0])}` : `${vars[p.ri].v} \\in ${set(p.inside)}`)); + out.push(""); + if (n.sentence) out.push(`\\* ${n.sentence}`); + out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); + invariants.push(n.name); +} +out.push("", "====", ""); + +const cfg = ["SPECIFICATION Spec", "INVARIANT TypeOK", ...invariants.map((n) => `INVARIANT ${n}`), ""]; + +const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); +const map = [ + `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, + `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, + `const LABELS = ${JSON.stringify(labels, null, "\t")};`, + "", + 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', + "export const step = (action) => LABELS[action] ?? action;", + "", +]; + +fs.mkdirSync(dir, { recursive: true }); +fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); +console.error( + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), ${invariants.length} invariant(s) -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, +); diff --git a/pstack/skills/statechart/scripts/compare-charts.mjs b/pstack/skills/statechart/scripts/compare-charts.mjs new file mode 100644 index 000000000..b3c2df1a9 --- /dev/null +++ b/pstack/skills/statechart/scripts/compare-charts.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Compare two Mermaid stateDiagram-v2 charts: states, start states, and labeled transitions. +// +// node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd +// +// Use it to check that a spec still draws the chart the reader agreed to: EXPECTED is the agreed chart, +// ACTUAL is what tlc-to-chart.mjs drew from the spec. When EXPECTED has parallel regions and ACTUAL +// has one, ACTUAL's states split on " | " into one state per region, in EXPECTED's region order +// (the order chart-to-tla.mjs's mapping writes them). An edge label "a or b" counts as two labels, +// and a highlighted path's step numbers ("2. ") are not part of the label. +// Exits 1 when the charts differ. +import fs from "node:fs"; +import process from "node:process"; +import { parseMermaid } from "./lib.mjs"; + +const [expectedFile, actualFile] = process.argv.slice(2); +if (!expectedFile || !actualFile) { + console.error("Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"); + process.exit(2); +} +const expected = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; +let actual = parseMermaid(fs.readFileSync(actualFile, "utf8")).regions; + +const names = Object.keys(expected); +if (names.length > 1 && Object.keys(actual).length === 1) { + const [only] = Object.values(actual); + const split = (s) => { + const parts = s.split(" | "); + if (parts.length !== names.length) throw new Error(`state "${s}" does not split into ${names.length} regions on " | "`); + return parts; + }; + actual = Object.fromEntries(names.map((n) => [n, { states: new Set(), initial: new Set(), transitions: [] }])); + for (const s of only.states) split(s).forEach((p, i) => actual[names[i]].states.add(p)); + for (const s of only.initial) split(s).forEach((p, i) => actual[names[i]].initial.add(p)); + for (const t of only.transitions) { + const [from, to] = [split(t.from), split(t.to)]; + names.forEach((n, i) => { + if (from[i] !== to[i]) actual[n].transitions.push({ from: from[i], to: to[i], label: t.label }); + }); + } +} + +const edgeSet = (r) => { + const out = new Set(); + for (const t of r.transitions) { + const labels = t.label.replace(/^\d+(, \d+)*\. /, "").replace(/ or …$/, "").split(" or "); + for (const l of labels) out.add(`${t.from} --> ${t.to}${l ? ` : ${l}` : ""}`); + } + return out; +}; +const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + +let differences = 0; +for (const name of new Set([...names, ...Object.keys(actual)])) { + const e = expected[name]; + const a = actual[name]; + if (!e || !a) { + console.log(`Region ${name}: only in ${e ? "EXPECTED" : "ACTUAL"}`); + differences++; + continue; + } + const rows = [ + ["states only in EXPECTED (no path from the start reaches them, or the spec renamed them)", minus(e.states, a.states)], + ["states only in ACTUAL", minus(a.states, e.states)], + ["start states only in EXPECTED", minus(e.initial, a.initial)], + ["start states only in ACTUAL", minus(a.initial, e.initial)], + ["transitions only in EXPECTED", minus(edgeSet(e), edgeSet(a))], + ["transitions only in ACTUAL", minus(edgeSet(a), edgeSet(e))], + ].filter(([, xs]) => xs.length > 0); + if (rows.length === 0) continue; + console.log(`Region ${name}:`); + for (const [title, xs] of rows) { + console.log(` ${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; + } +} +console.log(differences === 0 ? "same" : `${differences} difference(s)`); +process.exit(differences === 0 ? 0 : 1); diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs new file mode 100644 index 000000000..03b569877 --- /dev/null +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -0,0 +1,360 @@ +// Shared parsing for the statechart scripts. Node built-ins only. + +// ---------- TLA+ values as TLC prints them ---------- + +export function parseValue(text) { + const src = text.trim(); + let i = 0; + const ws = () => { + while (i < src.length && /\s/.test(src[i])) i++; + }; + const peek = (s) => src.startsWith(s, i); + const eat = (s) => { + ws(); + if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); + i += s.length; + }; + const list = (close, item) => { + const out = []; + ws(); + if (peek(close)) { + i += close.length; + return out; + } + for (;;) { + out.push(item()); + ws(); + if (peek(",")) { + i++; + continue; + } + eat(close); + return out; + } + }; + const value = () => { + ws(); + if (peek("<<")) { + i += 2; + return list(">>", value); + } + if (peek("{")) { + i++; + return list("}", value); + } + if (peek("[")) { + i++; + const pairs = list("]", () => { + ws(); + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad record field at ${i}`); + i += m[0].length; + eat("|->"); + return [m[0], value()]; + }); + return Object.fromEntries(pairs); + } + if (peek("(")) { + i++; + const fn = {}; + for (;;) { + const k = value(); + eat(":>"); + fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); + ws(); + if (peek("@@")) { + i += 2; + continue; + } + eat(")"); + return fn; + } + } + if (peek('"')) { + i++; + let s = ""; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") i++; + s += src[i++]; + } + i++; + return s; + } + const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad value at ${i} in ${src}`); + i += m[0].length; + if (m[0] === "TRUE") return true; + if (m[0] === "FALSE") return false; + return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; + }; + try { + const v = value(); + ws(); + return i === src.length ? v : src; + } catch { + return src; + } +} + +// One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. +export function parseState(text) { + const lines = text.split("\n").filter((l) => l.trim() !== ""); + const conjuncts = []; + for (const line of lines) { + if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) conjuncts.push(line.replace(/^\s*\/\\ /, "")); + else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; + } + const vars = {}; + for (const c of conjuncts) { + const eq = c.indexOf(" = "); + if (eq < 0) continue; + vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); + } + return vars; +} + +export function stateKey(vars) { + return JSON.stringify(Object.keys(vars).sort().map((k) => [k, vars[k]])); +} + +// ---------- TLC -dump dot,actionlabels ---------- + +function unescapeDot(s) { + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const n = s[++i]; + out += n === "n" ? "\n" : n; + } else out += s[i]; + } + return out; +} + +export function parseDot(text) { + const nodes = new Map(); + const edges = []; + const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; + const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); + const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); + for (const line of text.split("\n")) { + let m = edgeRe.exec(line); + if (m) { + if (m[1] !== m[2]) edges.push({ from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }); + continue; + } + m = nodeRe.exec(line); + if (m) nodes.set(m[1], { id: m[1], vars: parseState(unescapeDot(m[2])), init: /style\s*=\s*filled/.test(m[3]) }); + } + if (nodes.size === 0) throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); + const missing = edges.find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + if (missing) throw new Error(`transition ${missing.from} -> ${missing.to} names a state the file does not define`); + return { nodes, edges }; +} + +// ---------- TLC error traces (one per violation; many with -continue) ---------- + +export function parseTraces(text) { + const traces = []; + let invariant = null; + let current = null; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const inv = /^Error: Invariant (\S+) is violated/.exec(line); + if (inv) { + invariant = inv[1]; + continue; + } + const head = /^State (\d+): <([^\s>(]+)/.exec(line); + if (!head) continue; + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + const step = { action: head[2] === "Initial" ? null : head[2], vars: parseState(body.join("\n")) }; + if (head[1] === "1") { + current = { invariant, steps: [] }; + traces.push(current); + } + current?.steps.push(step); + } + return traces; +} + +// ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- + +export function wrap(text, width = 26) { + const words = String(text).split(/\s+/); + const rows = []; + let row = ""; + for (const w of words) { + if (row && (row + " " + w).length > width) { + rows.push(row); + row = w; + } else row = row ? `${row} ${w}` : w; + } + if (row) rows.push(row); + return rows.join("
"); +} + +const unwrap = (s) => s.replace(//g, " ").replace(/\s+/g, " ").trim(); + +// The scripts draw their legend as a composite with this id. It is not part of the machine. +export const LEGEND_ID = "legend"; + +// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label}] } }, nevers }. +// Composite states flatten to "Parent.Child". A transition on a composite leaves from each state +// inside it that has no transition with the same label, and enters at the composite's [*] start. +// One top-level composite split by "--" makes one region per part. Regions nest only at the top. +export function parseMermaid(text) { + const names = new Map(); + const nevers = []; + const newPart = () => ({ children: [], transitions: [] }); + const root = { name: "", parts: [newPart()] }; + const stack = [root]; + const part = () => stack[stack.length - 1].parts.at(-1); + const display = (id) => names.get(id) ?? id; + for (const raw of text.split("\n")) { + const line = raw.trim(); + const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); + if (never) { + nevers.push({ name: never[1], states: never[2].split("&").map((s) => s.trim()), sentence: never[3] ?? "" }); + continue; + } + if (!line || line.startsWith("%%") || /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line)) continue; + let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (!m) { + const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (plain) m = [line, null, plain[1], plain[2]]; + } + if (m) { + if (m[1] !== null) names.set(m[2], unwrap(m[1])); + if (m[3]) { + const node = { name: m[2], parts: [newPart()] }; + part().children.push(node); + stack.push(node); + } else part().children.push({ name: m[2], leaf: true }); + continue; + } + if (line === "}") { + if (stack.length === 1) throw new Error("unmatched } in the chart"); + stack.pop(); + continue; + } + if (line === "--") { + stack[stack.length - 1].parts.push(newPart()); + continue; + } + m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); + if (m) { + part().transitions.push({ from: m[1], to: m[2], label: m[3] ? unwrap(m[3]) : "" }); + continue; + } + m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); + if (m) { + names.set(m[1], unwrap(m[2])); + part().children.push({ name: m[1], leaf: true }); + continue; + } + throw new Error(`cannot read chart line: ${line}`); + } + if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + + // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. + // A composite's full name prefixes its children ("Parent.Child"). An id used only in a + // transition is a plain state in the part where it first appears. + function region(p) { + const out = { states: new Set(), initial: new Set(), transitions: [] }; + const info = new Map(); + const declared = new Set(); + (function collect(q) { + for (const c of q.children) { + if (c.name === LEGEND_ID) continue; + declared.add(c.name); + if (!c.leaf) for (const sub of c.parts) collect(sub); + } + })(p); + const transitions = []; + const lookup = (id) => { + const found = info.get(id); + if (!found) throw new Error(`transition names ${id}, which is not in this region`); + return found; + }; + function states(q, prefix, depth) { + const local = q.children.filter((c) => c.name !== LEGEND_ID); + for (const t of q.transitions) { + for (const end of [t.from, t.to]) { + if (end === "[*]" || declared.has(end)) continue; + declared.add(end); + local.push({ name: end, leaf: true }); + } + } + const all = []; + for (const c of local) { + if (info.has(c.name)) { + if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; + throw new Error(`state id ${c.name} is declared in two places`); + } + const full = `${prefix}${display(c.name)}`; + if (c.leaf) { + out.states.add(full); + info.set(c.name, { all: [full], initial: [full], depth }); + all.push(full); + continue; + } + if (c.parts.length > 1) throw new Error(`${display(c.name)} splits into regions below the top level; make each region a top-level region`); + const inner = states(c.parts[0], `${full}.`, depth + 1); + const initial = c.parts[0].transitions.filter((t) => t.from === "[*]").flatMap((t) => lookup(t.to).initial); + if (initial.length === 0) throw new Error(`${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`); + info.set(c.name, { all: inner, initial, depth }); + all.push(...inner); + } + for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); + return all; + } + states(p, "", 0); + for (const t of p.transitions) if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); + // The deepest source wins: a composite's transition leaves only from states inside it + // that have no deeper transition with the same label. + transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); + const handled = new Set(); + let pending = []; + let depth = null; + for (const t of transitions) { + const from = lookup(t.from); + if (from.depth !== depth) { + for (const h of pending) handled.add(h); + pending = []; + depth = from.depth; + } + for (const f of from.all) { + const key = `${f}\u0000${t.label}`; + if (t.label && handled.has(key)) continue; + for (const to of lookup(t.to).initial) out.transitions.push({ from: f, to, label: t.label }); + if (t.label) pending.push(key); + } + } + return out; + } + + const regions = {}; + const top = root.parts[0]; + const machine = top.children.filter((c) => c.name !== LEGEND_ID); + const parallel = machine.length === 1 && !machine[0].leaf && machine[0].parts.length > 1 && top.transitions.every((t) => t.from === "[*]"); + if (parallel) { + for (const p of machine[0].parts) { + const [only] = p.children; + const holder = p.children.length === 1 && !only.leaf && p.transitions.every((t) => t.from === "[*]" && t.to === only.name) ? only : null; + if (holder && holder.parts.length > 1) throw new Error(`${display(holder.name)} splits into regions below the top level`); + regions[holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`] = region(holder ? holder.parts[0] : p); + } + } else regions.Main = region(top); + return { regions, nevers }; +} + +export const STATUS_CLASSES = [ + "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", + "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", + "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", + "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", + "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", +]; diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs new file mode 100644 index 000000000..d73cc9da9 --- /dev/null +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -0,0 +1,176 @@ +// node --test scripts/test/statechart.test.mjs +// Set TLA2TOOLS (path to tla2tools.jar) and JAVA (java binary, default "java") to run the TLC tests too. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const scripts = dirname(here); +const fixture = (name) => readFileSync(join(here, name), "utf8"); +const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); +const node = (script, args, cwd) => spawnSync(process.execPath, [join(scripts, script), ...args], { cwd, encoding: "utf8" }); + +test("a transition on a composite leaves from every state inside it", () => { + const { regions } = parseMermaid(fixture("upload.mmd")); + const main = regions.Main; + assert.deepEqual([...main.states].sort(), ["Nothing chosen", "Upload failed", "Uploaded", "Uploading.Checking the file", "Uploading.Sending the file"]); + assert.deepEqual([...main.initial], ["Nothing chosen"]); + assert.deepEqual(edges(main), [ + "Nothing chosen -> Uploading.Sending the file : you choose a file", + "Upload failed -> Uploading.Sending the file : you retry", + "Uploading.Checking the file -> Nothing chosen : you cancel", + "Uploading.Checking the file -> Upload failed : the network drops", + "Uploading.Checking the file -> Uploaded : the check passes", + "Uploading.Sending the file -> Nothing chosen : you cancel", + "Uploading.Sending the file -> Upload failed : the network drops", + "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", + ]); +}); + +test("the deepest source wins when a composite and a state inside it share a label", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state Form { + [*] --> Typing + Typing --> Confirm : you press Escape + state Confirm + } + [*] --> Form + Form --> Closed : you press Escape + Confirm --> Closed : you press Escape`); + assert.deepEqual(edges(regions.Main), ["Form.Confirm -> Closed : you press Escape", "Form.Typing -> Form.Confirm : you press Escape"]); +}); + +test("a top-level composite split by -- makes one region per part", () => { + const { regions, nevers } = parseMermaid(fixture("sync.mmd")); + assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); + assert.deepEqual(edges(regions["Sync badge"]), [ + "Synced -> Waiting to sync : the network drops", + "Synced -> Waiting to sync : you edit a note", + "Waiting to sync -> Synced : the server confirms", + ]); + assert.deepEqual(nevers, [{ name: "NeverSyncedWhileOffline", states: ["Offline", "Synced"], sentence: 'You never see "Synced" while you are offline.' }]); +}); + +test("the legend is not part of the machine", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state "Legend" as legend { + state "Works today" as legendWorks + } + [*] --> A + A --> B : go`); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); +}); + +test("the parser refuses charts it cannot read", () => { + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); + assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), /no \[\*\] --> start/); + assert.throws( + () => parseMermaid("stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A"), + /below the top level/, + ); +}); + +test("TLC values, states, graphs, and traces parse", () => { + assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); + assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); + const graph = parseDot(`strict digraph DiskGraph { +1 [label="/\\\\ x = 0",style = filled] +2 [label="/\\\\ x = 1"] +1 -> 2 [label="Inc",color="black",fontcolor="black"]; +2 -> 2 [label="Stay"]; +}`); + assert.equal(graph.nodes.get("1").init, true); + assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); + assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + const [trace] = parseTraces(`Error: Invariant Small is violated. +Error: The behavior up to this point is: +State 1: +x = 0 + +State 2: +x = 1 +`); + assert.equal(trace.invariant, "Small"); + assert.deepEqual(trace.steps, [{ action: null, vars: { x: 0 } }, { action: "Inc", vars: { x: 1 } }]); +}); + +test("chart-to-tla writes one action per label and one invariant per never line", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + assert.match(spec, /^VARIABLES connection, syncBadge$/m); + assert.match(spec, /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m); + assert.match(spec, /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m); + assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n"); +}); + +test("chart-to-tla refuses a never line that names no state", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "c.mmd"), "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n"); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /names "C", which is not a state/); +}); + +const jar = process.env.TLA2TOOLS; +const java = process.env.JAVA ?? "java"; +const needsTlc = { skip: jar ? false : "set TLA2TOOLS to run TLC" }; + +function tlc(dir, name) { + const r = spawnSync( + java, + ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", "-dump", "dot,actionlabels", `${name}.dot`, "-config", `${name}.cfg`, `${name}.tla`], + { cwd: dir, encoding: "utf8" }, + ); + writeFileSync(join(dir, `${name}.out`), r.stdout); + return r; +} + +test("chart to TLA+ to chart gives back the same chart", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); + assert.equal(node("chart-to-tla.mjs", ["upload.mmd", "--module", "Upload"], dir).status, 0); + assert.match(tlc(dir, "Upload").stdout, /Model checking completed\. No error has been found\./); + const drawn = node("tlc-to-chart.mjs", ["--dot", "Upload.dot", "--map", "Upload.map.mjs", "--highlight", "0", "--out", "again.mmd"], dir); + assert.equal(drawn.status, 0, drawn.stderr); + const same = node("compare-charts.mjs", ["upload.mmd", "again.mmd"], dir); + assert.equal(same.stdout.trim(), "same"); +}); + +test("a never line finds the design bug, and the fixed model marks it fixed", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + assert.equal(node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir).status, 0); + // With -continue, TLC exits 0 even when it finds a violation. + assert.match(tlc(dir, "Sync").stdout, /Invariant NeverSyncedWhileOffline is violated/); + + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + const guarded = spec.replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n').replace("MODULE Sync", "MODULE SyncFixed"); + assert.notEqual(guarded, spec); + writeFileSync(join(dir, "SyncFixed.tla"), guarded); + writeFileSync(join(dir, "SyncFixed.cfg"), readFileSync(join(dir, "Sync.cfg"))); + assert.doesNotMatch(tlc(dir, "SyncFixed").stdout, /is violated/); + + const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", "Sync.out", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + const before = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(before.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 2]]); + assert.equal(before.chartStatus.wrong, 1); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /1\. the network drops\. Now: Offline \| Waiting to sync\n2\. the server confirms\. Now: Offline \| Synced/); + assert.match(readFileSync(join(dir, "chart.mmd"), "utf8"), /: 2\. the server confirms\n/); + + const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", "SyncFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const after = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(after.chartStatus, { works: 3, wrong: 0, fixed: 1 }); + assert.equal(after.classes[0].fix, "Offline | Waiting to sync"); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /`TheServerConfirms`\) cannot happen/); +}); diff --git a/pstack/skills/statechart/scripts/test/sync.mmd b/pstack/skills/statechart/scripts/test/sync.mmd new file mode 100644 index 000000000..c780c2e43 --- /dev/null +++ b/pstack/skills/statechart/scripts/test/sync.mmd @@ -0,0 +1,19 @@ +stateDiagram-v2 + state "Notes app" as App { + state "Connection" as Connection { + [*] --> Online + Online --> Offline : the network drops + Offline --> Online : the network returns + } + -- + state "Sync badge" as Sync { + state "Synced" as Synced + state "Waiting to sync" as Pending + [*] --> Synced + Synced --> Pending : you edit a note + Synced --> Pending : the network drops + Pending --> Synced : the server confirms + } + } + [*] --> App + %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. diff --git a/pstack/skills/statechart/scripts/test/upload.mmd b/pstack/skills/statechart/scripts/test/upload.mmd new file mode 100644 index 000000000..dcfafa737 --- /dev/null +++ b/pstack/skills/statechart/scripts/test/upload.mmd @@ -0,0 +1,16 @@ +stateDiagram-v2 + state "Nothing chosen" as Idle + state "Uploading" as Uploading { + state "Sending the file" as Sending + state "Checking the file" as Checking + [*] --> Sending + Sending --> Checking : the last byte arrives + } + state "Uploaded" as Done + state "Upload failed" as Failed + [*] --> Idle + Idle --> Uploading : you choose a file + Checking --> Done : the check passes + Uploading --> Failed : the network drops + Failed --> Uploading : you retry + Uploading --> Idle : you cancel diff --git a/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/pstack/skills/statechart/scripts/tlc-to-chart.mjs new file mode 100644 index 000000000..8cd315a90 --- /dev/null +++ b/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +// Collapse a TLC state graph onto the reader's states and write a Mermaid stateDiagram-v2. +// +// node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [options] +// +// --dot FILE state graph from TLC -dump dot,actionlabels FILE (the current code) +// --map FILE mapping module: state(vars, facts), optional detail(vars, facts), +// step(action, before, after), cause(vars, facts, invariant) (the hidden reason a bad state is bad; +// counterexamples with the same invariant and cause are one bug), and +// readerActions (the actions the reader takes). +// facts.enabled is the set of actions the state enables; facts.settled is true when +// only reader actions are enabled, so nothing else happens until the reader acts. +// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state) +// --fixed-dot FILE state graph of the model with the open fix; counterexamples replay against it +// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still wrong +// --view NAME customer (state labels), developer (detail labels), or layered (detail inside state) +// --cover keep only the shortest path to each settled reader state, plus counterexample paths +// --highlight N draw counterexample class N as a numbered path (default 1; 0 for none) +// --path-only draw only the highlighted path +// --title TEXT chart title +// --out FILE Mermaid output (default stdout) +// --story FILE reader stories, one per counterexample class +// --summary FILE JSON counts for the chart and the classes +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { LEGEND_ID, parseDot, parseTraces, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; + +const args = { traces: [], fixedTraces: [], view: "customer", highlight: 1 }; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + const next = () => process.argv[++i]; + if (a === "--dot") args.dot = next(); + else if (a === "--map") args.map = next(); + else if (a === "--traces") args.traces.push(next()); + else if (a === "--fixed-dot") args.fixedDot = next(); + else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--view") args.view = next(); + else if (a === "--highlight") args.highlight = Number(next()); + else if (a === "--path-only") args.pathOnly = true; + else if (a === "--cover") args.cover = true; + else if (a === "--title") args.title = next(); + else if (a === "--out") args.out = next(); + else if (a === "--story") args.story = next(); + else if (a === "--summary") args.summary = next(); + else { + console.error(`unknown argument ${a}`); + process.exit(2); + } +} +if (!args.dot || !args.map) { + console.error("Usage: node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [--traces tlc.out] [--fixed-dot fixed.dot] [--view customer|developer|layered]"); + process.exit(2); +} +if (!["customer", "developer", "layered"].includes(args.view)) { + console.error(`--view must be customer, developer, or layered, not ${args.view}`); + process.exit(2); +} + +const mapping = await import(pathToFileURL(path.resolve(args.map)).href); +if (typeof mapping.state !== "function") throw new Error("the mapping module must export state(vars)"); +if (args.view !== "customer" && typeof mapping.detail !== "function") throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); +const SEP = "\u0000"; + +const readerActions = new Set(mapping.readerActions ?? []); +const factsFrom = (enabled) => ({ enabled, settled: [...enabled].every((a) => readerActions.has(a)) }); +let factsOf = () => factsFrom(new Set()); +function keyOf(vars) { + const facts = factsOf(vars); + if (args.view === "developer") return String(mapping.detail(vars, facts)); + const outer = mapping.state(vars, facts); + if (typeof outer !== "string") throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); + return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; +} +function stepLabel(action, before, after) { + const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; + return args.view === "customer" || said === action ? said : `${said} [${action}]`; +} + +function quotient(graph) { + const keys = new Map(); + for (const n of graph.nodes.values()) keys.set(n.id, keyOf(n.vars)); + const nodes = new Set(keys.values()); + const initial = new Set([...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id))); + const edges = new Map(); + for (const e of graph.edges) { + const from = keys.get(e.from); + const to = keys.get(e.to); + if (from === to) continue; + const id = `${from}${SEP}${SEP}${to}`; + if (!edges.has(id)) edges.set(id, { from, to, counts: new Map() }); + const label = stepLabel(e.action, graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars); + if (label) edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); + } + // Most common wording first. A chart edge shows two at most. + for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); + const settled = new Set([...graph.nodes.values()].filter((n) => factsOf(n.vars).settled).map((n) => keys.get(n.id))); + return { nodes, initial, edges, keys, settled }; +} + +function enabledIndex(g) { + const byId = new Map([...g.nodes.keys()].map((id) => [id, new Set()])); + for (const e of g.edges) byId.get(e.from).add(e.action); + return new Map([...g.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); +} +function useFacts(index) { + factsOf = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set()); +} + +const graph = parseDot(fs.readFileSync(args.dot, "utf8")); +const graphIndex = enabledIndex(graph); +useFacts(graphIndex); +const now = quotient(graph); +const fixedGraph = args.fixedDot ? parseDot(fs.readFileSync(args.fixedDot, "utf8")) : null; +const fixedIndex = fixedGraph ? enabledIndex(fixedGraph) : null; +let fixed = null; +if (fixedGraph) { + useFacts(fixedIndex); + fixed = quotient(fixedGraph); + useFacts(graphIndex); +} + +const customerKey = (vars) => String(mapping.state(vars, factsOf(vars))); + +// Reader path of one trace: drop system steps that keep the reader in the same state. +// Reader actions stay even then, because the reader did them. +function readerPath(trace, key0 = keyOf) { + const out = [{ key: key0(trace.steps[0].vars), label: null, modelStep: 0 }]; + for (let i = 1; i < trace.steps.length; i++) { + const key = key0(trace.steps[i].vars); + if (key === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; + const label = stepLabel(trace.steps[i].action, trace.steps[i - 1].vars, trace.steps[i].vars) || trace.steps[i].action; + out.push({ key, label, modelStep: i }); + } + return out; +} + +// Replay a trace's actions in the fixed graph. Returns where the fixed model leaves the trace. +function replayFixed(trace) { + if (!fixedGraph) return null; + const byKey = new Map([...fixedGraph.nodes.values()].map((n) => [stateKey(n.vars), n])); + const out = new Map(); + for (const e of fixedGraph.edges) { + if (!out.has(e.from)) out.set(e.from, []); + out.get(e.from).push(e); + } + let cur = byKey.get(stateKey(trace.steps[0].vars)); + if (!cur) return { step: 0, reason: "the fixed model has no matching initial state" }; + for (let i = 1; i < trace.steps.length; i++) { + const want = trace.steps[i]; + const same = (out.get(cur.id) ?? []).filter((e) => e.action === want.action); + const exact = same.find((e) => stateKey(fixedGraph.nodes.get(e.to).vars) === stateKey(want.vars)); + if (exact) { + cur = fixedGraph.nodes.get(exact.to); + continue; + } + const score = (vars) => Object.keys(want.vars).filter((k) => JSON.stringify(vars[k]) === JSON.stringify(want.vars[k])).length; + const best = same.map((e) => fixedGraph.nodes.get(e.to)).sort((a, b) => score(b.vars) - score(a.vars))[0]; + const reached = best ?? cur; + useFacts(fixedIndex); + const key = keyOf(reached.vars); + useFacts(graphIndex); + return { step: i, action: want.action, disabled: !best, key }; + } + return { step: trace.steps.length, sameEnd: true }; +} + +// With -continue, TLC reports every bad state, including states after the first bad one. +// Cut each trace at its first bad state so one bug counts once. +function firstBad(files) { + const raw = files.flatMap((f) => parseTraces(fs.readFileSync(f, "utf8"))); + const badStates = new Map(); + for (const t of raw) { + if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); + badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); + } + const cuts = []; + const seen = new Set(); + for (const t of raw) { + const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); + const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; + const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; + if (seen.has(id)) continue; + seen.add(id); + cuts.push(cut); + } + return cuts; +} +const traces = firstBad(args.traces); +const fixedTraces = firstBad(args.fixedTraces); +// Classes and their shortest traces come from the customer states in every view, +// so class N is the same bug and the same trace in the customer and developer charts. +const classesById = new Map(); +for (const trace of traces) { + const rp = readerPath(trace, customerKey); + const last = trace.steps[trace.steps.length - 1].vars; + const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = classesById.get(id); + if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { + classesById.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); + } else prev.count++; +} +const classes = [...classesById.values()].sort( + (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), +); +for (const c of classes) { + c.path = readerPath(c.trace); + c.end = c.path[c.path.length - 1].key; + c.fix = replayFixed(c.trace); +} + +// ---------- status ---------- +// A chart state is still wrong when the fixed model's checks still find a first bad state in it, +// and fixed when only the current model's checks do. Without --fixed-traces every bad state is wrong. +const isFixed = (c) => c.fix?.key !== undefined && c.fix.key !== c.end; +const endKeys = (list) => new Set(list.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); +const badNow = endKeys(traces); +useFacts(fixedIndex ?? graphIndex); +const badFixed = args.fixedTraces.length > 0 ? endKeys(fixedTraces) : badNow; +useFacts(graphIndex); +const allNodes = new Set([...now.nodes, ...(fixed?.nodes ?? [])]); +const status = (k) => (badFixed.has(k) ? "wrong" : badNow.has(k) || (fixed && !now.nodes.has(k)) ? "fixed" : "works"); + +const edges = new Map(now.edges); +if (fixed) { + for (const [id, e] of fixed.edges) { + if (!edges.has(id)) edges.set(id, { ...e, labels: new Set([...e.labels].map((l) => `${l} (with the fix)`)), fixedOnly: true }); + } +} +for (const c of classes) { + if (isFixed(c)) { + const id = `${c.end}${SEP}${SEP}${c.fix.key}`; + if (!edges.has(id)) edges.set(id, { from: c.end, to: c.fix.key, labels: new Set(["with the fix"]), fixedOnly: true }); + } +} + +// ---------- highlighted path ---------- +const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; +if (args.highlight > 0 && classes.length > 0 && !chosen) throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); +const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); +const pathNumbers = new Map(); +if (chosen) { + chosen.path.slice(1).forEach((p, i) => { + const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; + if (!pathNumbers.has(id)) pathNumbers.set(id, { n: [], label: p.label }); + pathNumbers.get(id).n.push(i + 1); + if (!edges.has(id)) edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); + }); + if (isFixed(chosen)) onPath.add(chosen.fix.key); +} + +// ---------- cover: shortest path to each settled reader state ---------- +let coverEdges = null; +if (args.cover) { + const keepEdges = new Set(); + const bfs = (q, targets) => { + const parent = new Map([...q.initial].map((k) => [k, null])); + const queue = [...q.initial]; + const outEdges = new Map(); + for (const [id, e] of q.edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from).push([id, e]); + } + while (queue.length) { + const k = queue.shift(); + for (const [id, e] of outEdges.get(k) ?? []) { + if (parent.has(e.to)) continue; + parent.set(e.to, id); + queue.push(e.to); + } + } + for (const t of targets) { + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); + } + }; + bfs(now, now.settled); + if (fixed) bfs(fixed, [...fixed.settled].filter((k) => !now.nodes.has(k))); + for (const c of classes) { + c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); + if (isFixed(c)) keepEdges.add(`${c.end}${SEP}${SEP}${c.fix.key}`); + } + for (const id of keepEdges) { + if (!edges.has(id)) { + const [from, to] = id.split(`${SEP}${SEP}`); + const c = classes.find((x) => x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to)); + const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); + edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); + } + } + coverEdges = keepEdges; +} + +// ---------- Mermaid ---------- +let keep = allNodes; +if (args.pathOnly && chosen) keep = onPath; +else if (coverEdges) { + keep = new Set([...now.initial, ...(fixed?.initial ?? [])]); + for (const id of coverEdges) for (const k of id.split(`${SEP}${SEP}`)) keep.add(k); +} +const ids = new Map(); +let counter = 0; +const idOf = (k) => { + if (!ids.has(k)) ids.set(k, `s${counter++}`); + return ids.get(k); +}; +const label = (k) => (args.view === "layered" ? k.split(SEP)[1] : k); +const out = []; +if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); +out.push("stateDiagram-v2"); +for (const c of STATUS_CLASSES) out.push(` ${c}`); +out.push(` state "Legend" as ${LEGEND_ID} {`); +out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed in an open PR" as legendFixed'); +out.push(" }", " class legendWorks works", " class legendWrong wrong", " class legendFixed fixed"); +const sorted = [...keep].sort(); +if (args.view === "layered") { + const groups = new Map(); + for (const k of sorted) { + const outer = k.split(SEP)[0]; + if (!groups.has(outer)) groups.set(outer, []); + groups.get(outer).push(k); + } + let g = 0; + for (const [outer, members] of groups) { + out.push(` state "${wrap(outer)}" as g${g++} {`); + for (const k of members) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); + out.push(" }"); + } +} else { + for (const k of sorted) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); +} +const initial = new Set([...now.initial, ...(fixed?.initial ?? [])]); +for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); +let edgeCount = 0; +for (const [id, e] of edges) { + if (!keep.has(e.from) || !keep.has(e.to)) continue; + if (args.pathOnly && !pathNumbers.has(id) && !e.labels.has("with the fix")) continue; + if (coverEdges && !coverEdges.has(id)) continue; + const all = [...e.labels]; + let text = all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + const p = pathNumbers.get(id); + if (p) text = `${p.n.join(", ")}. ${p.label}`; + // ";" ends a Mermaid statement, even inside a transition label. + text = text.replaceAll(";", ","); + out.push(` ${idOf(e.from)} --> ${idOf(e.to)}${text ? ` : ${wrap(text, 30)}` : ""}`); + edgeCount++; +} +for (const k of sorted) out.push(` class ${idOf(k)} ${status(k)}${onPath.has(k) ? "Path" : ""}`); +const mermaid = out.join("\n") + "\n"; +if (args.out) fs.writeFileSync(args.out, mermaid); +else process.stdout.write(mermaid); + +// ---------- stories and summary ---------- +const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); +if (args.story) { + const lines = []; + classes.forEach((c, i) => { + lines.push(`## ${i + 1}. ${show(c.end)}`, ""); + lines.push(`Breaks \`${c.invariant ?? "unknown"}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); + c.path.forEach((p, j) => lines.push(j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${p.label}. Now: ${show(p.key)}`)); + lines.push(""); + if (!c.fix) lines.push("No fixed model given."); + else if (c.fix.sameEnd) lines.push("The fixed model follows the whole trace: the fix does not change this path."); + else if (c.fix.reason) lines.push(`The fixed model cannot replay this trace: ${c.fix.reason}.`); + else lines.push(`With the fix, model step ${c.fix.step} (\`${c.fix.action}\`) ${c.fix.disabled ? "cannot happen" : "goes elsewhere"}, and you see: ${show(c.fix.key)}`); + lines.push(""); + }); + fs.writeFileSync(args.story, lines.join("\n")); +} +const summary = { + view: args.view, + modelStates: graph.nodes.size, + modelTransitions: graph.edges.length, + chartStates: keep.size, + chartTransitions: edgeCount, + chartStatus: Object.fromEntries(["works", "wrong", "fixed"].map((s) => [s, [...keep].filter((k) => status(k) === s).length])), + classes: classes.map((c, i) => ({ + n: i + 1, + end: show(c.end), + invariant: c.invariant, + cause: c.cause, + readerSteps: c.path.length - 1, + modelSteps: c.trace.steps.length, + counterexamples: c.count, + fix: c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, + })), +}; +if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); +console.error(`chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ${classes.length} counterexample class(es)`); From f149a2dbd4e8fa7b21f671f999d8de59bf936163 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 03:21:51 +0000 Subject: [PATCH 2/9] Fix the review findings in the statechart skill - Check each rule in its own TLC run. The generated cfg names no rule, and a comment in it lists the rules. - Refuse TLC output from a run that failed or stopped early, and read counterexamples that a start state breaks. - Count steps that change nothing when a state is settled, number bugs that only the fixed model has after the current bugs, and escape quotes in captions. - Compare parallel regions as one machine, and refuse charts that were not drawn for comparison. - Refuse final states and duplicate captions, read class shorthand and states first named inside a composite, and match a never line to an exact state. - Rewrite SKILL.md to match, and run the skill script tests in CI. --- .github/workflows/validate-plugins.yml | 3 + .../claude-code/skills/statechart/SKILL.md | 57 +-- .../statechart/scripts/chart-to-tla.mjs | 11 +- .../statechart/scripts/compare-charts.mjs | 108 +++--- .../skills/statechart/scripts/lib.mjs | 140 ++++++- .../scripts/test/statechart.test.mjs | 352 +++++++++++++++++- .../statechart/scripts/tlc-to-chart.mjs | 151 +++++--- pstack/skills/statechart/SKILL.md | 57 +-- .../statechart/scripts/chart-to-tla.mjs | 11 +- .../statechart/scripts/compare-charts.mjs | 108 +++--- pstack/skills/statechart/scripts/lib.mjs | 140 ++++++- .../scripts/test/statechart.test.mjs | 352 +++++++++++++++++- .../statechart/scripts/tlc-to-chart.mjs | 151 +++++--- 13 files changed, 1351 insertions(+), 290 deletions(-) diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index 1c8450be0..c3edbc934 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -30,3 +30,6 @@ jobs: - name: Check generated pstack harness trees run: node scripts/generate-pstack-harness-trees.mjs --check + + - name: Test pstack skill scripts + run: node --test pstack/skills/*/scripts/test/*.test.mjs diff --git a/harness/pstack/claude-code/skills/statechart/SKILL.md b/harness/pstack/claude-code/skills/statechart/SKILL.md index 56a8c7491..8d2ece4f0 100644 --- a/harness/pstack/claude-code/skills/statechart/SKILL.md +++ b/harness/pstack/claude-code/skills/statechart/SKILL.md @@ -16,28 +16,31 @@ A PM, a designer, or a reviewer can judge a chart of what they see. They cannot ## Setup - Java 11 or later and `tla2tools.jar` from the [TLA+ releases](https://github.com/tlaplus/tlaplus/releases). Set `TLA2TOOLS` to the jar's path. -- Node. The scripts in `scripts/` next to this file have no dependencies. +- Node. The scripts in `scripts/` next to this file have no dependencies. `node --test scripts/test/statechart.test.mjs` checks them, and runs the TLC tests too when `TLA2TOOLS` is set. - To render, `npx -y @mermaid-js/mermaid-cli -i chart.mmd -o chart.png -s 2`. -Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. With `-continue`, TLC keeps going past a broken rule and exits 0, so read its output, not its exit code. +Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. ```bash tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -continue "$@"; } ``` +With `-continue`, TLC exits 0 after a broken rule, and its output still ends with "Model checking completed. No error has been found." So count the `Error: Invariant` lines. A nonzero exit, or no "Model checking completed" line, means the run failed or stopped early. Read the output to find why. `tlc-to-chart.mjs` refuses such a file. + ## Model to chart -1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. +1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen, or it names a fact they care about. When the harm is off screen, such as a second charge while the screen still says "Paying", put the fact in the name ("Paying, charged twice"). Otherwise the bug's path looks like a normal path. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. 2. **Write the reader's rules as invariants.** One sentence the reader agrees to is one invariant, with the sentence as its comment. For "once everything settles", define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the reader does not take. Never weaken a rule to make the chart pass. A broken rule is the finding. 3. **Write the mapping module.** It is an ES module that `tlc-to-chart.mjs` imports. ```js + // TLA+ actions the reader takes. facts.settled and --cover need them. export const readerActions = ["EditNote", "OpenApp"]; - // Reader words for one model state. facts.settled is true when only reader actions can happen. + // Reader words for one model state. export function state(v, facts) {} - // Edge label in reader words. Return "" to hide a step. + // Edge label in reader words. export function step(action, before, after) {} // Why a bad state is bad, in code terms. export function cause(v, facts, invariant) {} @@ -45,20 +48,25 @@ tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -contin export function detail(v, facts) {} ``` + - `v`, `before`, and `after` map each variable name to its value. Strings, numbers, and booleans become their JavaScript kind. Sequences, tuples, and sets become arrays, indexed from 0. Records become objects. A function `(a :> 1 @@ b :> 2)` becomes an object keyed by its domain. + - `facts.enabled` is the set of actions the state enables, including steps that change no variable. `facts.settled` is true when every enabled action is a reader action, so nothing else happens until the reader acts. + - `invariant` is the name of the broken rule. + - A step that leaves the reader in the same state is not drawn, whatever `step` returns. A reader action is the exception on a bug path, because the reader did it. When `step` returns `""`, the edge has no label in the overview, and a numbered bug step shows the TLA+ action name. + Counterexamples that break the same rule with the same `cause` are one bug. Two bugs that end on the same screen through different code paths stay apart only if `cause` names the code path, such as "the badge read the cache before the server answered". -4. **Run TLC.** One run dumps the state graph. Then run once per rule, because TLC reports only the first rule a state breaks. +4. **Run TLC.** Keep the rules out of `Spec.cfg`. TLC reports only the first rule a state breaks, so each rule gets its own run, with one `INVARIANT` line added to `Spec.cfg`. One more run dumps the state graph. ```bash tlc -dump dot,actionlabels Spec.dot -config Spec.cfg Spec.tla > Spec.out - for rule in NoStaleBadge NoLostEdit; do - { grep -v '^INVARIANT' Spec.cfg; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" + for rule in TypeOK NoStaleBadge NoLostEdit; do + { cat Spec.cfg; echo; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" tlc -config "Spec-$rule.cfg" Spec.tla > "Spec-$rule.out" - echo "$rule: $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" + echo "$rule: exit $?, $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" done ``` - If an open change fixes some of it, model the change as a second spec (`SpecFixed.tla`) and run the same commands on it. + If an open change fixes some of it, model the change as a second spec, `SpecFixed.tla` with `MODULE SpecFixed`. Copy `Spec.cfg` to `SpecFixed.cfg`, and run the same commands with `SpecFixed` in place of `Spec`. 5. **Draw the reader's chart.** @@ -71,44 +79,47 @@ tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -contin --out chart.mmd --story stories.md --summary summary.json ``` - Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Draw one chart per bug for the hand-off, and one with `--highlight 0` for the overview. + Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state, plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Bugs are numbered by fewest reader steps, then fewest model steps. Bugs that only the fixed model has come after the current bugs. Run once with `--highlight 0` to get the overview and the numbers in `summary.json`. Then draw one chart per bug for the hand-off. - Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and redraw. + Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and draw again. -6. **Show one bug's mechanism.** `--view developer --path-only --highlight N` draws bug N through the `detail` states. Do not draw a whole developer chart. On one model it had twelve times the states of the reader's chart. `--view layered` puts the developer states inside the reader states, for a reviewer who wants both on one page. +6. **Show one bug's mechanism.** Add `--view developer --path-only --highlight N` to the step 5 command to draw bug N through the `detail` states. Use `--view layered --path-only --highlight N` to put the developer states inside the reader states, for a reviewer who wants both on one page. Always use `--path-only` with these views. Without it, one model gave twelve times the states of the reader's chart. 7. **Reproduce every red path in the real code.** The model can be wrong. Replay each bug's story from `stories.md` as a test or a live repro against the real code, one step per model action. A bug you cannot reproduce is a modeling error until shown otherwise. Fix the model, not the rule. Report which bugs reproduced. ## Chart to model -1. **Write the chart** in Mermaid `stateDiagram-v2`. Split a top-level composite with `--` into regions. Each region is one variable. Each transition label is one action, and regions with the same label move together. Write each rule as a comment line. +1. **Write the chart** in Mermaid `stateDiagram-v2`. To make parallel regions, split one top-level composite with `--`. Wrap each part in a named composite, such as `state Connection { ... }`, to name its region and its variable. Each region is one variable. Each transition label is one action. An action can happen when at least one region can take that label. Every region that can take it moves, and the other regions stay where they are. An unlabeled transition is its own action. Write each rule as a comment line. ``` %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. ``` - Each name in a `never` line is a state, or a composite that stands for every state inside it. The names must come from different regions. The chart cannot hold history states. Draw "comes back to where it was" as an edge to that state. + Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, and regions below the top level. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. -2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`. +2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`, and prints the rules. `Sync.cfg` names no rule, and a comment in it lists them. Fill in `readerActions` in `Sync.map.mjs` with the actions the reader takes. -3. **Check the agreed chart.** Run step 4 of model to chart on `Sync`, then draw and compare. +3. **Check the agreed chart.** Run step 4 of model to chart on `Sync` with the rules it printed. Then draw the chart for comparison and compare. ```bash - node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out --highlight 0 --out drawn.mmd + node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out \ + --highlight 0 --all-labels --out drawn.mmd node scripts/compare-charts.mjs chart.mmd drawn.mmd ``` - `compare-charts.mjs` prints `same` when the drawn chart has the same states and transitions. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. + Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses other charts and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. 4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. ## Read the chart -- Gray states work today. Red states are still wrong, because the checks still reach a first bad state there with the fix in. Green states are fixed by the open change. Without `--fixed-*`, every bad state is red. +- Gray states work today. Red states are still wrong with the fix in. Green states are fixed by the open change, or new with it. Without `--fixed-*`, every bad state is red. +- A state is bad when it breaks a rule, or when every path from the start to it passes a state that breaks one. What the reader sees after a bug is part of that bug. - A thick blue border and numbered labels mark the highlighted bug. Mermaid state diagrams cannot color an edge. -- An edge shows its two most common labels. "or …" means there are more. -- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. -- `summary.json` has the counts behind the chart. Quote it, not an estimate. +- An edge labeled "with the fix" leaves the state before the step that the fix changes, and ends where the fixed model goes instead. An edge labeled "blocked by the fix" loops on the state where the fix stops that step. An edge labeled "(with the fix)" exists only in the fixed model. +- An edge shows its two most common labels. "or …" means there are more. `--all-labels` shows them all. +- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. A bug that only the fixed model has is marked "New with the fix". +- `summary.json` has the counts behind the chart. Quote it, not an estimate. Its counterexample counts are lower than the counts from the step 4 loop. TLC reports each bad state, including the bad states after the first one on a path. `summary.json` counts only the paths that end on their first bad state. ## Hand off diff --git a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs index 2721c88b5..d671f48ee 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs @@ -10,6 +10,7 @@ // A name in a never line is a state, or a composite that stands for every state inside it. // // Writes NAME.tla, NAME.cfg, and NAME.map.mjs (for tlc-to-chart.mjs) into OUT, next to the chart by default. +// NAME.cfg names no rule; a comment in it lists the rules, and each TLC run appends one INVARIANT line. import fs from "node:fs"; import path from "node:path"; import process from "node:process"; @@ -120,7 +121,7 @@ out.push("Spec == Init /\\ [][Next]_vars"); function resolve(item) { const hits = []; for (const [ri, x] of vars.entries()) { - const inside = [...x.states].filter((s) => s === item || s.startsWith(`${item}.`)); + const inside = x.states.has(item) ? [item] : [...x.states].filter((s) => s.startsWith(`${item}.`)); if (inside.length) hits.push({ ri, inside }); } if (hits.length === 0) throw new Error(`never line names "${item}", which is not a state in the chart`); @@ -142,7 +143,9 @@ for (const n of nevers) { } out.push("", "====", ""); -const cfg = ["SPECIFICATION Spec", "INVARIANT TypeOK", ...invariants.map((n) => `INVARIANT ${n}`), ""]; +// TLC reports only the first rule a state breaks, so the config names no rule and each TLC run appends one. +const rules = ["TypeOK", ...invariants]; +const cfg = ["SPECIFICATION Spec", `\\* Rules, one TLC run each: ${rules.join(" ")}`, ""]; const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); const map = [ @@ -150,6 +153,8 @@ const map = [ `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, `const LABELS = ${JSON.stringify(labels, null, "\t")};`, "", + "// The actions the reader takes. facts.settled and --cover need them. Add cause() and detail() as the spec grows.", + "export const readerActions = [];", 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', "export const step = (action) => LABELS[action] ?? action;", "", @@ -160,5 +165,5 @@ fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); console.error( - `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), ${invariants.length} invariant(s) -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, ); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs index b3c2df1a9..17094370e 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs @@ -1,79 +1,83 @@ #!/usr/bin/env node -// Compare two Mermaid stateDiagram-v2 charts: states, start states, and labeled transitions. +// Compare two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled transitions. // // node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd // // Use it to check that a spec still draws the chart the reader agreed to: EXPECTED is the agreed chart, -// ACTUAL is what tlc-to-chart.mjs drew from the spec. When EXPECTED has parallel regions and ACTUAL -// has one, ACTUAL's states split on " | " into one state per region, in EXPECTED's region order -// (the order chart-to-tla.mjs's mapping writes them). An edge label "a or b" counts as two labels, -// and a highlighted path's step numbers ("2. ") are not part of the label. -// Exits 1 when the charts differ. +// ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer view with --highlight 0 --all-labels, +// without --cover or --fixed-dot. A chart with parallel regions compares as its combined machine +// (productOf in lib.mjs), so a guard that blocks a step in only some combinations of regions shows as a +// difference. A combined state is the region states joined by " | " in region order, which is what +// chart-to-tla.mjs's mapping writes. An edge label "a or b" counts as two labels. An unlabeled edge counts +// only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only the labels. +// Exits 1 when the charts differ and 2 when ACTUAL is not drawn for comparison. import fs from "node:fs"; import process from "node:process"; -import { parseMermaid } from "./lib.mjs"; +import { parseMermaid, productOf } from "./lib.mjs"; const [expectedFile, actualFile] = process.argv.slice(2); if (!expectedFile || !actualFile) { console.error("Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"); process.exit(2); } -const expected = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; -let actual = parseMermaid(fs.readFileSync(actualFile, "utf8")).regions; -const names = Object.keys(expected); -if (names.length > 1 && Object.keys(actual).length === 1) { - const [only] = Object.values(actual); - const split = (s) => { - const parts = s.split(" | "); - if (parts.length !== names.length) throw new Error(`state "${s}" does not split into ${names.length} regions on " | "`); - return parts; - }; - actual = Object.fromEntries(names.map((n) => [n, { states: new Set(), initial: new Set(), transitions: [] }])); - for (const s of only.states) split(s).forEach((p, i) => actual[names[i]].states.add(p)); - for (const s of only.initial) split(s).forEach((p, i) => actual[names[i]].initial.add(p)); - for (const t of only.transitions) { - const [from, to] = [split(t.from), split(t.to)]; - names.forEach((n, i) => { - if (from[i] !== to[i]) actual[n].transitions.push({ from: from[i], to: to[i], label: t.label }); - }); +const machine = (regions) => { + const rs = Object.values(regions); + return rs.length === 1 ? rs[0] : productOf(regions); +}; +const expectedRegions = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; +const expected = machine(expectedRegions); +const actual = machine(parseMermaid(fs.readFileSync(actualFile, "utf8")).regions); + +const refusals = [ + [(l) => / or …$/.test(l), "an edge label ends in \" or …\"; draw ACTUAL with --all-labels"], + [(l) => /^\d+(, \d+)*\. /.test(l), "an edge label is a numbered path step; draw ACTUAL with --highlight 0"], + [(l) => /with the fix\)?$|blocked by the fix$/.test(l), "an edge comes from the fixed model; draw ACTUAL without --fixed-dot"], +]; +for (const [bad, why] of refusals) { + if (actual.transitions.some((t) => bad(t.label))) { + console.error(`${actualFile}: ${why}`); + process.exit(2); } } -const edgeSet = (r) => { +const edgeSet = (m) => { + const labeled = new Set(); const out = new Set(); - for (const t of r.transitions) { - const labels = t.label.replace(/^\d+(, \d+)*\. /, "").replace(/ or …$/, "").split(" or "); - for (const l of labels) out.add(`${t.from} --> ${t.to}${l ? ` : ${l}` : ""}`); + for (const t of m.transitions) { + if (!t.label) continue; + labeled.add(`${t.from} --> ${t.to}`); + for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); } + for (const t of m.transitions) if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); return out; }; const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); +// A region state that no combination of the agreed chart's own steps reaches can never be drawn. +const unreached = []; +if (Object.keys(expectedRegions).length > 1) { + Object.entries(expectedRegions).forEach(([name, r], ri) => { + const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); + for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); + }); +} + +const rows = [ + ["region states the agreed chart's own steps never reach", unreached.sort()], + ["states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", minus(expected.states, actual.states)], + ["states only in ACTUAL", minus(actual.states, expected.states)], + ["start states only in EXPECTED", minus(expected.initial, actual.initial)], + ["start states only in ACTUAL", minus(actual.initial, expected.initial)], + ["transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", minus(edgeSet(expected), edgeSet(actual))], + ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], +].filter(([, xs]) => xs.length > 0); + let differences = 0; -for (const name of new Set([...names, ...Object.keys(actual)])) { - const e = expected[name]; - const a = actual[name]; - if (!e || !a) { - console.log(`Region ${name}: only in ${e ? "EXPECTED" : "ACTUAL"}`); - differences++; - continue; - } - const rows = [ - ["states only in EXPECTED (no path from the start reaches them, or the spec renamed them)", minus(e.states, a.states)], - ["states only in ACTUAL", minus(a.states, e.states)], - ["start states only in EXPECTED", minus(e.initial, a.initial)], - ["start states only in ACTUAL", minus(a.initial, e.initial)], - ["transitions only in EXPECTED", minus(edgeSet(e), edgeSet(a))], - ["transitions only in ACTUAL", minus(edgeSet(a), edgeSet(e))], - ].filter(([, xs]) => xs.length > 0); - if (rows.length === 0) continue; - console.log(`Region ${name}:`); - for (const [title, xs] of rows) { - console.log(` ${title}:`); - for (const x of xs) console.log(` ${x}`); - differences += xs.length; - } +for (const [title, xs] of rows) { + console.log(`${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; } console.log(differences === 0 ? "same" : `${differences} difference(s)`); process.exit(differences === 0 ? 0 : 1); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index 03b569877..b620e2d87 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -130,55 +130,85 @@ function unescapeDot(s) { return out; } +// Returns { nodes, edges, loops }. loops are steps that change no variable; they count as enabled +// actions but are not transitions. export function parseDot(text) { const nodes = new Map(); const edges = []; + const loops = []; + const seen = new Set(); const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); for (const line of text.split("\n")) { let m = edgeRe.exec(line); if (m) { - if (m[1] !== m[2]) edges.push({ from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }); + const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; + const id = `${e.from} ${e.to} ${e.action}`; + if (seen.has(id)) continue; + seen.add(id); + (e.from === e.to ? loops : edges).push(e); continue; } m = nodeRe.exec(line); if (m) nodes.set(m[1], { id: m[1], vars: parseState(unescapeDot(m[2])), init: /style\s*=\s*filled/.test(m[3]) }); } if (nodes.size === 0) throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); - const missing = edges.find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); if (missing) throw new Error(`transition ${missing.from} -> ${missing.to} names a state the file does not define`); - return { nodes, edges }; + if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) throw new Error("the graph has no action names; dump it with TLC -dump dot,actionlabels "); + return { nodes, edges, loops }; } // ---------- TLC error traces (one per violation; many with -continue) ---------- +// A trace that follows an error other than a broken invariant has invariant null. export function parseTraces(text) { const traces = []; let invariant = null; let current = null; const lines = text.split("\n"); + const stateAt = (i) => { + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + return [parseState(body.join("\n")), i]; + }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; - const inv = /^Error: Invariant (\S+) is violated/.exec(line); + const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); if (inv) { invariant = inv[1]; + if (inv[2]) { + const [vars, end] = stateAt(i); + traces.push({ invariant, steps: [{ action: null, vars }] }); + i = end; + } continue; } + if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) invariant = null; const head = /^State (\d+): <([^\s>(]+)/.exec(line); if (!head) continue; - const body = []; - while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); - const step = { action: head[2] === "Initial" ? null : head[2], vars: parseState(body.join("\n")) }; + const [vars, end] = stateAt(i); + i = end; if (head[1] === "1") { current = { invariant, steps: [] }; traces.push(current); } - current?.steps.push(step); + current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); } return traces; } +// Throws unless TLC finished and every error it printed is a broken invariant. With -continue, TLC +// prints "Model checking completed" even after broken invariants, and not when it stopped early. +export function checkTlcOutput(text, file) { + const other = text + .split("\n") + .find((l) => l.startsWith("Error: ") && !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l)); + if (other) throw new Error(`${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`); + if (!/^Model checking completed\./m.test(text)) throw new Error(`${file}: TLC did not finish, so its counterexamples are not all there; read the file`); +} + // ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- export function wrap(text, width = 26) { @@ -195,7 +225,15 @@ export function wrap(text, width = 26) { return rows.join("
"); } -const unwrap = (s) => s.replace(//g, " ").replace(/\s+/g, " ").trim(); +const unwrap = (s) => + s + .replace(//g, " ") + .replace(/#(quot|34);/g, '"') + .replace(/\s+/g, " ") + .trim(); + +// Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. +export const quote = (s) => String(s).replaceAll('"', "#quot;"); // The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; @@ -212,8 +250,20 @@ export function parseMermaid(text) { const stack = [root]; const part = () => stack[stack.length - 1].parts.at(-1); const display = (id) => names.get(id) ?? id; + // As in Mermaid, a state that only transitions name belongs to the part where it first appears. + const implicit = new Map(); + const declare = (id) => { + const prev = implicit.get(id); + if (prev) prev.children.splice(prev.children.findIndex((c) => c.name === id), 1); + implicit.set(id, null); + }; + const mention = (id) => { + if (id === "[*]" || implicit.has(id)) return; + part().children.push({ name: id, leaf: true }); + implicit.set(id, part()); + }; for (const raw of text.split("\n")) { - const line = raw.trim(); + const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); if (never) { nevers.push({ name: never[1], states: never[2].split("&").map((s) => s.trim()), sentence: never[3] ?? "" }); @@ -226,7 +276,8 @@ export function parseMermaid(text) { if (plain) m = [line, null, plain[1], plain[2]]; } if (m) { - if (m[1] !== null) names.set(m[2], unwrap(m[1])); + if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); + declare(m[2]); if (m[3]) { const node = { name: m[2], parts: [newPart()] }; part().children.push(node); @@ -245,12 +296,18 @@ export function parseMermaid(text) { } m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); if (m) { + if (m[2] === "[*]" && m[1] !== "[*]") throw new Error(`cannot read final state in "${line}"; draw the step to the state the reader sees next`); + mention(m[1]); + mention(m[2]); part().transitions.push({ from: m[1], to: m[2], label: m[3] ? unwrap(m[3]) : "" }); continue; } m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); if (m) { - names.set(m[1], unwrap(m[2])); + // Mermaid shows every description line; the first one names the state. + if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); + if (implicit.get(m[1]) === null) continue; + declare(m[1]); part().children.push({ name: m[1], leaf: true }); continue; } @@ -295,6 +352,7 @@ export function parseMermaid(text) { } const full = `${prefix}${display(c.name)}`; if (c.leaf) { + if (out.states.has(full)) throw new Error(`two states have the caption "${full}"; give each its own caption`); out.states.add(full); info.set(c.name, { all: [full], initial: [full], depth }); all.push(full); @@ -350,6 +408,64 @@ export function parseMermaid(text) { return { regions, nevers }; } +// The combined machine of a chart's regions, as chart-to-tla.mjs generates it. A labeled step can +// happen when at least one region can take it; every region that can take it moves, the others stay. +// An unlabeled transition moves its own region only. A combined state is the region states joined +// by " | " in region order. Only states reachable from the start are kept, and steps that change +// nothing are dropped, as TLC's state graph drops them. +export function productOf(regions) { + const rs = Object.values(regions); + const join = (parts) => parts.join(" | "); + const byLabel = new Map(); + const unlabeled = []; + rs.forEach((r, ri) => { + for (const t of r.transitions) { + if (!t.label) { + unlabeled.push({ ri, from: t.from, to: t.to }); + continue; + } + if (!byLabel.has(t.label)) byLabel.set(t.label, rs.map(() => new Map())); + const moves = byLabel.get(t.label)[ri]; + if (!moves.has(t.from)) moves.set(t.from, new Set()); + moves.get(t.from).add(t.to); + } + }); + let starts = [[]]; + for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); + const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; + const seen = new Set(starts.map(join)); + const queue = [...starts]; + const step = (from, to, label) => { + const [a, b] = [join(from), join(to)]; + if (a === b) return; + out.transitions.push({ from: a, to: b, label }); + if (!seen.has(b)) { + seen.add(b); + queue.push(to); + } + }; + while (queue.length) { + const cur = queue.shift(); + out.states.add(join(cur)); + for (const u of unlabeled) { + if (cur[u.ri] !== u.from) continue; + const next = [...cur]; + next[u.ri] = u.to; + step(cur, next, ""); + } + for (const [label, perRegion] of byLabel) { + if (!perRegion.some((moves, ri) => moves.has(cur[ri]))) continue; + let nexts = [[]]; + perRegion.forEach((moves, ri) => { + const tos = moves.has(cur[ri]) ? [...moves.get(cur[ri])] : [cur[ri]]; + nexts = nexts.flatMap((p) => tos.map((s) => [...p, s])); + }); + for (const next of nexts) step(cur, next, label); + } + } + return out; +} + export const STATUS_CLASSES = [ "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index d73cc9da9..54fb161f9 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; +import { checkTlcOutput, parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const scripts = dirname(here); @@ -74,6 +74,38 @@ test("the parser refuses charts it cannot read", () => { () => parseMermaid("stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A"), /below the top level/, ); + assert.throws(() => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), /final state/); + assert.throws( + () => parseMermaid('stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go'), + /two states have the caption "Something went wrong"/, + ); +}); + +test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + classDef wrong fill:#fdd + N : Nothing chosen + N : press Upload to start + [*] --> N + N:::wrong --> Done : you finish + Done --> Moving:::wrong + state A { + [*] --> X + X --> Y : go + } + Moving --> A : start + Y --> Z : next + A --> N : cancel`); + assert.deepEqual([...regions.Main.states].sort(), ["A.X", "A.Y", "Done", "Moving", "Nothing chosen", "Z"]); + assert.deepEqual(edges(regions.Main), [ + "A.X -> A.Y : go", + "A.X -> Nothing chosen : cancel", + "A.Y -> Nothing chosen : cancel", + "A.Y -> Z : next", + "Done -> Moving : ", + "Moving -> A.X : start", + "Nothing chosen -> Done : you finish", + ]); }); test("TLC values, states, graphs, and traces parse", () => { @@ -83,11 +115,14 @@ test("TLC values, states, graphs, and traces parse", () => { 1 [label="/\\\\ x = 0",style = filled] 2 [label="/\\\\ x = 1"] 1 -> 2 [label="Inc",color="black",fontcolor="black"]; +1 -> 2 [label="Inc",color="black",fontcolor="black"]; 2 -> 2 [label="Stay"]; }`); assert.equal(graph.nodes.get("1").init, true); assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); + assert.throws(() => parseDot('strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}'), /no action names/); const [trace] = parseTraces(`Error: Invariant Small is violated. Error: The behavior up to this point is: State 1: @@ -100,6 +135,73 @@ x = 1 assert.deepEqual(trace.steps, [{ action: null, vars: { x: 0 } }, { action: "Inc", vars: { x: 1 } }]); }); +const done = "Model checking completed. No error has been found.\n"; +const evaluationError = `Error: Invariant NoLost is violated. +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Lost" + +Error: TLC threw an unexpected exception. +This was probably caused by an error in the spec or model. + +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Saving" + +3 states generated, 3 distinct states found, 0 states left on queue. +`; + +test("a rule broken by a start state is a counterexample of one state", () => { + const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: +/\\ badge = "Synced" +/\\ conn = "Offline" + +${done}`; + assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.deepEqual(parseTraces(text), [{ invariant: "NeverSyncedWhileOffline", steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }] }]); +}); + +test("TLC output from a run that failed or stopped early is refused", () => { + assert.doesNotThrow(() => checkTlcOutput(`Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, "ok.out")); + assert.throws(() => checkTlcOutput("Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", "cfg.out"), /cfg\.out: TLC stopped on an error that is not a broken rule/); + assert.throws(() => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), /not a broken rule/); + assert.throws(() => checkTlcOutput(evaluationError, "eval.out"), /TLC threw an unexpected exception/); + assert.throws(() => checkTlcOutput("Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", "stopped.out"), /did not finish/); + assert.deepEqual( + parseTraces(evaluationError).map((t) => t.invariant), + ["NoLost", null], + ); +}); + +test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "save.dot"), + `strict digraph DiskGraph { +1 [label="/\\\\ s = \\"Idle\\"",style = filled] +2 [label="/\\\\ s = \\"Saving\\""] +1 -> 2 [label="Tap"]; +2 -> 2 [label="Retry"]; +}`, + ); + writeFileSync( + join(dir, "save.map.mjs"), + `export const readerActions = ["Tap"]; +export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving"); +export const step = (action) => ({ Tap: "you tap Save" })[action] ?? ""; +`, + ); + const r = node("tlc-to-chart.mjs", ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), ['Badge says "Synced" -> Saving : you tap Save']); +}); + test("chart-to-tla writes one action per label and one invariant per never line", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); @@ -109,7 +211,8 @@ test("chart-to-tla writes one action per label and one invariant per never line" assert.match(spec, /^VARIABLES connection, syncBadge$/m); assert.match(spec, /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m); assert.match(spec, /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m); - assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n"); + assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\n\\* Rules, one TLC run each: TypeOK NeverSyncedWhileOffline\n"); + assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); }); test("chart-to-tla refuses a never line that names no state", () => { @@ -120,17 +223,105 @@ test("chart-to-tla refuses a never line that names no state", () => { assert.match(r.stderr, /names "C", which is not a state/); }); +test("a never line names a state exactly, or every state inside a composite", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + `stateDiagram-v2 + state "Saving" as S + state "Saving..." as S2 + state Busy { + [*] --> Checking + Checking --> Sending : the check passes + } + [*] --> S + S --> S2 : you wait + S2 --> Busy : you send + Busy --> S : you cancel + %% never NoSaving: Saving -- You never see Saving. + %% never NoBusy: Busy -- You are never busy. +`, + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "C.tla"), "utf8"); + assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); + assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); + assert.match(readFileSync(join(dir, "C.map.mjs"), "utf8"), /^export const readerActions = \[\];$/m); +}); + +const door = `stateDiagram-v2 + state Door { + [*] --> Locked + Locked --> Unlocked : tap unlock + Unlocked --> Locked : tap lock + -- + [*] --> Closed + Closed --> Open : open the door + Open --> Closed : close the door + } + [*] --> Door`; +const flatDoor = (extra = []) => + [ + "stateDiagram-v2", + ' state "Locked | Closed" as L_C', + ' state "Locked | Open" as L_O', + ' state "Unlocked | Closed" as U_C', + ' state "Unlocked | Open" as U_O', + " [*] --> L_C", + " L_C --> U_C : tap unlock", + " U_C --> L_C : tap lock", + " L_O --> U_O : tap unlock", + " L_C --> L_O : open the door", + " L_O --> L_C : close the door", + " U_C --> U_O : open the door", + " U_O --> U_C : close the door", + ...extra, + ].join("\n"); + +test("compare reads parallel regions as one machine, so a guard across regions shows", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + writeFileSync(join(dir, "guarded.mmd"), flatDoor()); + writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); + const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); + assert.equal(differ.status, 1); + assert.match(differ.stdout, /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/); + const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); + assert.equal(same.stdout.trim(), "same", same.stdout); +}); + +test("compare refuses an ACTUAL chart that is not drawn for comparison", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + for (const [label, why] of [ + ["tap lock or …", /--all-labels/], + ["1. tap lock", /--highlight 0/], + ["tap lock (with the fix)", /without --fixed-dot/], + ["tap lock, blocked by the fix", /without --fixed-dot/], + ]) { + writeFileSync(join(dir, "actual.mmd"), flatDoor([` U_O --> L_O : ${label}`])); + const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); + assert.equal(r.status, 2, label); + assert.match(r.stderr, why); + } +}); + const jar = process.env.TLA2TOOLS; const java = process.env.JAVA ?? "java"; const needsTlc = { skip: jar ? false : "set TLA2TOOLS to run TLC" }; -function tlc(dir, name) { +// Without a rule, dump NAME.dot. With a rule, check it alone in NAME-RULE.out, as the SKILL.md loop does. +function tlc(dir, name, rule) { + const base = rule ? `${name}-${rule}` : name; + if (rule) writeFileSync(join(dir, `${base}.cfg`), `${readFileSync(join(dir, `${name}.cfg`), "utf8")}\nINVARIANT ${rule}\n`); + const dump = rule ? [] : ["-dump", "dot,actionlabels", `${name}.dot`]; const r = spawnSync( java, - ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", "-dump", "dot,actionlabels", `${name}.dot`, "-config", `${name}.cfg`, `${name}.tla`], + ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", ...dump, "-config", `${base}.cfg`, `${name}.tla`], { cwd: dir, encoding: "utf8" }, ); - writeFileSync(join(dir, `${name}.out`), r.stdout); + writeFileSync(join(dir, `${base}.out`), r.stdout); return r; } @@ -139,6 +330,9 @@ test("chart to TLA+ to chart gives back the same chart", needsTlc, () => { writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); assert.equal(node("chart-to-tla.mjs", ["upload.mmd", "--module", "Upload"], dir).status, 0); assert.match(tlc(dir, "Upload").stdout, /Model checking completed\. No error has been found\./); + const typeOk = tlc(dir, "Upload", "TypeOK"); + assert.equal(typeOk.status, 0, typeOk.stdout); + assert.doesNotMatch(typeOk.stdout, /^Error:/m); const drawn = node("tlc-to-chart.mjs", ["--dot", "Upload.dot", "--map", "Upload.map.mjs", "--highlight", "0", "--out", "again.mmd"], dir); assert.equal(drawn.status, 0, drawn.stderr); const same = node("compare-charts.mjs", ["upload.mmd", "again.mmd"], dir); @@ -149,17 +343,22 @@ test("a never line finds the design bug, and the fixed model marks it fixed", ne const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); assert.equal(node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir).status, 0); - // With -continue, TLC exits 0 even when it finds a violation. - assert.match(tlc(dir, "Sync").stdout, /Invariant NeverSyncedWhileOffline is violated/); + const rule = "NeverSyncedWhileOffline"; + tlc(dir, "Sync"); + // With -continue, TLC exits 0 and still prints "Model checking completed" after a violation. + const broken = tlc(dir, "Sync", rule); + assert.equal(broken.status, 0); + assert.match(broken.stdout, /Invariant NeverSyncedWhileOffline is violated[^]*Model checking completed\. No error has been found\./); const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); const guarded = spec.replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n').replace("MODULE Sync", "MODULE SyncFixed"); assert.notEqual(guarded, spec); writeFileSync(join(dir, "SyncFixed.tla"), guarded); writeFileSync(join(dir, "SyncFixed.cfg"), readFileSync(join(dir, "Sync.cfg"))); - assert.doesNotMatch(tlc(dir, "SyncFixed").stdout, /is violated/); + tlc(dir, "SyncFixed"); + assert.doesNotMatch(tlc(dir, "SyncFixed", rule).stdout, /is violated/); - const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", "Sync.out", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; + const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", `Sync-${rule}.out`, "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); const before = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); assert.deepEqual(before.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 2]]); @@ -167,10 +366,143 @@ test("a never line finds the design bug, and the fixed model marks it fixed", ne assert.match(readFileSync(join(dir, "story.md"), "utf8"), /1\. the network drops\. Now: Offline \| Waiting to sync\n2\. the server confirms\. Now: Offline \| Synced/); assert.match(readFileSync(join(dir, "chart.mmd"), "utf8"), /: 2\. the server confirms\n/); - const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", "SyncFixed.out"]; + const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", `SyncFixed-${rule}.out`]; assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); const after = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); assert.deepEqual(after.chartStatus, { works: 3, wrong: 0, fixed: 1 }); assert.equal(after.classes[0].fix, "Offline | Waiting to sync"); assert.match(readFileSync(join(dir, "story.md"), "utf8"), /`TheServerConfirms`\) cannot happen/); }); + +const pay = (name, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges +vars == <> +Init == screen = "Cart" /\\ charges = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED charges +Charge == ${charge} +Refund == charges = 2 /\\ charges' = 1 /\\ screen' = "Refunded" +Next == Pay \\/ Charge \\/ Refund +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const payMap = `export const readerActions = ["Pay"]; +export const state = (v) => v.screen + ["", ", charged", ", charged twice"][v.charges]; +export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you", Refund: "the bank refunds you" })[action] ?? ""; +export const cause = () => "each charge request charges again"; +`; + +test("a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; + writeFileSync(join(dir, "Pay.tla"), pay("Pay", `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`)); + writeFileSync( + join(dir, "PayFixed.tla"), + pay("PayFixed", `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`), + ); + writeFileSync(join(dir, "Pay.cfg"), cfg); + writeFileSync(join(dir, "PayFixed.cfg"), cfg); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); + + const statusOf = (chart) => { + const names = new Map([...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [m[2], m[1].replace(//g, " ")])); + return Object.fromEntries([...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]])); + }; + const args = ["--dot", "Pay.dot", "--map", "pay.map.mjs", "--traces", "Pay.out", "--highlight", "0", "--out", "chart.mmd"]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { + Cart: "works", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "wrong", + "Refunded, charged": "wrong", + }); + + const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); + assert.deepEqual(statusOf(chart), { + Cart: "works", + "Paid, charged": "fixed", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "fixed", + "Refunded, charged": "fixed", + }); + const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => t.label.endsWith("with the fix")); + assert.deepEqual( + fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), + ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], + ); +}); + +test("a rule broken by a start state marks that state wrong", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Start.tla"), + `---- MODULE Start ---- +VARIABLES conn, badge +vars == <> +Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced" +Drop == conn = "Online" /\\ conn' = "Offline" /\\ badge' = "Waiting" +Next == Drop +Spec == Init /\\ [][Next]_vars +NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced") +==== +`, + ); + writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); + writeFileSync(join(dir, "start.map.mjs"), 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n'); + assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); + const args = ["--dot", "Start.dot", "--map", "start.map.mjs", "--traces", "Start.out", "--summary", "summary.json", "--out", "chart.mmd"]; + const r = node("tlc-to-chart.mjs", args, dir); + assert.equal(r.status, 0, r.stderr); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(summary.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 0]]); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); +}); + +const edit = (name, fixed) => `---- MODULE ${name} ---- +VARIABLES s, flashed +vars == <> +Init == s = "Idle" /\\ flashed = FALSE +Tap == s = "Idle" /\\ s' = "Saving" /\\ UNCHANGED flashed +Lose == s = "Saving" /\\ s' = "Lost edit" /\\ UNCHANGED flashed +Flash == s = "Saving" /\\ s' = "Flash" /\\ flashed' = ${fixed ? "TRUE" : "FALSE"} +Back == s \\in {"Lost edit", "Flash"} /\\ s' = "Idle" /\\ flashed' = FALSE +Next == Tap \\/ Flash \\/ Back${fixed ? "" : " \\/ Lose"} +Spec == Init /\\ [][Next]_vars +NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed) +==== +`; + +test("a bug that only the fixed model has is numbered after the current bugs and drawn wrong", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; + for (const [name, fixed] of [["Edit", false], ["EditFixed", true]]) { + writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); + writeFileSync(join(dir, `${name}.cfg`), cfg); + assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); + } + writeFileSync( + join(dir, "edit.map.mjs"), + 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', + ); + const args = ["--dot", "Edit.dot", "--map", "edit.map.mjs", "--traces", "Edit.out", "--fixed-dot", "EditFixed.dot", "--fixed-traces", "EditFixed.out"]; + const r = node("tlc-to-chart.mjs", [...args, "--cover", "--highlight", "0", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(summary.classes.map((c) => [c.n, c.end, c.fix]), [ + [1, "Lost edit", "Saving"], + [2, "Flash", "new with the fix"], + ]); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); + const two = node("tlc-to-chart.mjs", [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], dir); + assert.equal(two.status, 0, two.stderr); + assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); +}); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs index 8cd315a90..0a3ffdb42 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs @@ -10,13 +10,16 @@ // readerActions (the actions the reader takes). // facts.enabled is the set of actions the state enables; facts.settled is true when // only reader actions are enabled, so nothing else happens until the reader acts. -// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state) +// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state). +// A file from a TLC run that stopped early or hit an error other than a broken rule is refused. // --fixed-dot FILE state graph of the model with the open fix; counterexamples replay against it -// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still wrong +// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still +// wrong, and adds the bugs that only the fixed model has after the current model's bugs // --view NAME customer (state labels), developer (detail labels), or layered (detail inside state) // --cover keep only the shortest path to each settled reader state, plus counterexample paths // --highlight N draw counterexample class N as a numbered path (default 1; 0 for none) // --path-only draw only the highlighted path +// --all-labels write every label on an edge (default: the two most common, then " or …") // --title TEXT chart title // --out FILE Mermaid output (default stdout) // --story FILE reader stories, one per counterexample class @@ -25,7 +28,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { LEGEND_ID, parseDot, parseTraces, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; +import { LEGEND_ID, checkTlcOutput, parseDot, parseTraces, quote, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; const args = { traces: [], fixedTraces: [], view: "customer", highlight: 1 }; for (let i = 2; i < process.argv.length; i++) { @@ -39,6 +42,7 @@ for (let i = 2; i < process.argv.length; i++) { else if (a === "--view") args.view = next(); else if (a === "--highlight") args.highlight = Number(next()); else if (a === "--path-only") args.pathOnly = true; + else if (a === "--all-labels") args.allLabels = true; else if (a === "--cover") args.cover = true; else if (a === "--title") args.title = next(); else if (a === "--out") args.out = next(); @@ -93,7 +97,7 @@ function quotient(graph) { const label = stepLabel(e.action, graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars); if (label) edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); } - // Most common wording first. A chart edge shows two at most. + // Most common wording first. A chart edge shows two unless --all-labels. for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); const settled = new Set([...graph.nodes.values()].filter((n) => factsOf(n.vars).settled).map((n) => keys.get(n.id))); return { nodes, initial, edges, keys, settled }; @@ -101,7 +105,7 @@ function quotient(graph) { function enabledIndex(g) { const byId = new Map([...g.nodes.keys()].map((id) => [id, new Set()])); - for (const e of g.edges) byId.get(e.from).add(e.action); + for (const e of [...g.edges, ...g.loops]) byId.get(e.from).add(e.action); return new Map([...g.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); } function useFacts(index) { @@ -141,7 +145,7 @@ function replayFixed(trace) { if (!fixedGraph) return null; const byKey = new Map([...fixedGraph.nodes.values()].map((n) => [stateKey(n.vars), n])); const out = new Map(); - for (const e of fixedGraph.edges) { + for (const e of [...fixedGraph.edges, ...fixedGraph.loops]) { if (!out.has(e.from)) out.set(e.from, []); out.get(e.from).push(e); } @@ -166,10 +170,16 @@ function replayFixed(trace) { return { step: trace.steps.length, sameEnd: true }; } +function readTraces(files) { + return files.flatMap((f) => { + const text = fs.readFileSync(f, "utf8"); + checkTlcOutput(text, f); + return parseTraces(text); + }); +} // With -continue, TLC reports every bad state, including states after the first bad one. // Cut each trace at its first bad state so one bug counts once. -function firstBad(files) { - const raw = files.flatMap((f) => parseTraces(fs.readFileSync(f, "utf8"))); +function firstBad(raw) { const badStates = new Map(); for (const t of raw) { if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); @@ -187,38 +197,78 @@ function firstBad(files) { } return cuts; } -const traces = firstBad(args.traces); -const fixedTraces = firstBad(args.fixedTraces); +const rawTraces = readTraces(args.traces); +const rawFixedTraces = readTraces(args.fixedTraces); +const traces = firstBad(rawTraces); +const fixedTraces = firstBad(rawFixedTraces); // Classes and their shortest traces come from the customer states in every view, // so class N is the same bug and the same trace in the customer and developer charts. -const classesById = new Map(); -for (const trace of traces) { - const rp = readerPath(trace, customerKey); - const last = trace.steps[trace.steps.length - 1].vars; - const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; - const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; - const prev = classesById.get(id); - if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { - classesById.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); - } else prev.count++; +function group(cuts) { + const byId = new Map(); + for (const trace of cuts) { + const rp = readerPath(trace, customerKey); + const last = trace.steps[trace.steps.length - 1].vars; + const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = byId.get(id); + if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { + byId.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); + } else prev.count++; + } + return byId; } -const classes = [...classesById.values()].sort( - (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), -); +const byOrder = (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +const classesById = group(traces); +const classes = [...classesById.values()].sort(byOrder); for (const c of classes) { c.path = readerPath(c.trace); c.end = c.path[c.path.length - 1].key; c.fix = replayFixed(c.trace); } +// A bug of the fixed model that no current bug matches is new with the fix. It numbers after the current bugs. +useFacts(fixedIndex ?? graphIndex); +const introduced = [...group(fixedTraces).values()].filter((c) => !classesById.has(c.id)).sort(byOrder); +for (const c of introduced) { + c.path = readerPath(c.trace); + c.end = c.path[c.path.length - 1].key; + c.fix = null; + c.introduced = true; +} +useFacts(graphIndex); +classes.push(...introduced); // ---------- status ---------- -// A chart state is still wrong when the fixed model's checks still find a first bad state in it, -// and fixed when only the current model's checks do. Without --fixed-traces every bad state is wrong. +// A chart state is bad when it holds a state that breaks a rule, or when every path from the start to +// it passes such a state: what the reader sees after a bug is part of that bug. It is still wrong when +// it is bad in the fixed model, and fixed when it is bad only in the current model. +// Without --fixed-traces every bad state is wrong. const isFixed = (c) => c.fix?.key !== undefined && c.fix.key !== c.end; -const endKeys = (list) => new Set(list.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); -const badNow = endKeys(traces); +function badKeys(g, keys, raw) { + const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); + const out = new Set(raw.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); + if (!g) return out; + const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); + const next = new Map(); + for (const e of g.edges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); + const queue = [...clean]; + while (queue.length) { + for (const to of next.get(queue.shift()) ?? []) { + if (clean.has(to) || !ok(to)) continue; + clean.add(to); + queue.push(to); + } + } + const reached = new Set([...clean].map((id) => keys.get(id))); + for (const k of keys.values()) if (!reached.has(k)) out.add(k); + return out; +} +const badNow = badKeys(graph, now.keys, rawTraces); useFacts(fixedIndex ?? graphIndex); -const badFixed = args.fixedTraces.length > 0 ? endKeys(fixedTraces) : badNow; +const badFixed = args.fixedTraces.length > 0 ? badKeys(fixedGraph, fixed?.keys, rawFixedTraces) : badNow; useFacts(graphIndex); const allNodes = new Set([...now.nodes, ...(fixed?.nodes ?? [])]); const status = (k) => (badFixed.has(k) ? "wrong" : badNow.has(k) || (fixed && !now.nodes.has(k)) ? "fixed" : "works"); @@ -229,11 +279,18 @@ if (fixed) { if (!edges.has(id)) edges.set(id, { ...e, labels: new Set([...e.labels].map((l) => `${l} (with the fix)`)), fixedOnly: true }); } } +// The fix changes one model step of the counterexample. Its edge leaves the chart state before that +// step and ends where the fixed model goes instead, or stays put when the fix blocks the step. +const fixEdgeId = (c) => `${c.fix.from}${SEP}${SEP}${c.fix.key}${SEP}fix`; for (const c of classes) { - if (isFixed(c)) { - const id = `${c.end}${SEP}${SEP}${c.fix.key}`; - if (!edges.has(id)) edges.set(id, { from: c.end, to: c.fix.key, labels: new Set(["with the fix"]), fixedOnly: true }); - } + if (!isFixed(c)) continue; + const [before, after] = [c.trace.steps[c.fix.step - 1].vars, c.trace.steps[c.fix.step].vars]; + c.fix.from = keyOf(before); + const said = stepLabel(c.fix.action, before, after) || c.fix.action; + const id = fixEdgeId(c); + if (!edges.has(id)) edges.set(id, { from: c.fix.from, to: c.fix.key, labels: new Set(), fixedOnly: true, fixOf: new Set() }); + edges.get(id).labels.add(c.fix.disabled ? `${said}, blocked by the fix` : `${said}, with the fix`); + edges.get(id).fixOf.add(c); } // ---------- highlighted path ---------- @@ -279,7 +336,7 @@ if (args.cover) { if (fixed) bfs(fixed, [...fixed.settled].filter((k) => !now.nodes.has(k))); for (const c of classes) { c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); - if (isFixed(c)) keepEdges.add(`${c.end}${SEP}${SEP}${c.fix.key}`); + if (isFixed(c)) keepEdges.add(fixEdgeId(c)); } for (const id of keepEdges) { if (!edges.has(id)) { @@ -297,7 +354,7 @@ let keep = allNodes; if (args.pathOnly && chosen) keep = onPath; else if (coverEdges) { keep = new Set([...now.initial, ...(fixed?.initial ?? [])]); - for (const id of coverEdges) for (const k of id.split(`${SEP}${SEP}`)) keep.add(k); + for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); } const ids = new Map(); let counter = 0; @@ -311,7 +368,7 @@ if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); out.push("stateDiagram-v2"); for (const c of STATUS_CLASSES) out.push(` ${c}`); out.push(` state "Legend" as ${LEGEND_ID} {`); -out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed in an open PR" as legendFixed'); +out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed or new in an open PR" as legendFixed'); out.push(" }", " class legendWorks works", " class legendWrong wrong", " class legendFixed fixed"); const sorted = [...keep].sort(); if (args.view === "layered") { @@ -323,22 +380,22 @@ if (args.view === "layered") { } let g = 0; for (const [outer, members] of groups) { - out.push(` state "${wrap(outer)}" as g${g++} {`); - for (const k of members) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); + out.push(` state "${quote(wrap(outer))}" as g${g++} {`); + for (const k of members) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); out.push(" }"); } } else { - for (const k of sorted) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); + for (const k of sorted) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); } const initial = new Set([...now.initial, ...(fixed?.initial ?? [])]); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); let edgeCount = 0; for (const [id, e] of edges) { if (!keep.has(e.from) || !keep.has(e.to)) continue; - if (args.pathOnly && !pathNumbers.has(id) && !e.labels.has("with the fix")) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.fixOf?.has(chosen)) continue; if (coverEdges && !coverEdges.has(id)) continue; const all = [...e.labels]; - let text = all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + let text = args.allLabels ? all.join(" or ") : all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); const p = pathNumbers.get(id); if (p) text = `${p.n.join(", ")}. ${p.label}`; // ";" ends a Mermaid statement, even inside a transition label. @@ -356,11 +413,12 @@ const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); if (args.story) { const lines = []; classes.forEach((c, i) => { - lines.push(`## ${i + 1}. ${show(c.end)}`, ""); - lines.push(`Breaks \`${c.invariant ?? "unknown"}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); + lines.push(`## ${i + 1}. ${c.introduced ? "New with the fix: " : ""}${show(c.end)}`, ""); + lines.push(`Breaks \`${c.invariant}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); c.path.forEach((p, j) => lines.push(j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${p.label}. Now: ${show(p.key)}`)); lines.push(""); - if (!c.fix) lines.push("No fixed model given."); + if (c.introduced) lines.push("Only the fixed model has this bug. The path above is in the fixed model."); + else if (!c.fix) lines.push("No fixed model given."); else if (c.fix.sameEnd) lines.push("The fixed model follows the whole trace: the fix does not change this path."); else if (c.fix.reason) lines.push(`The fixed model cannot replay this trace: ${c.fix.reason}.`); else lines.push(`With the fix, model step ${c.fix.step} (\`${c.fix.action}\`) ${c.fix.disabled ? "cannot happen" : "goes elsewhere"}, and you see: ${show(c.fix.key)}`); @@ -383,8 +441,11 @@ const summary = { readerSteps: c.path.length - 1, modelSteps: c.trace.steps.length, counterexamples: c.count, - fix: c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, + fix: c.introduced ? "new with the fix" : c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, })), }; if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); -console.error(`chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ${classes.length} counterexample class(es)`); +console.error( + `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ` + + `${classes.length - introduced.length} bug(s)${introduced.length ? `, ${introduced.length} new with the fix` : ""}`, +); diff --git a/pstack/skills/statechart/SKILL.md b/pstack/skills/statechart/SKILL.md index 56a8c7491..8d2ece4f0 100644 --- a/pstack/skills/statechart/SKILL.md +++ b/pstack/skills/statechart/SKILL.md @@ -16,28 +16,31 @@ A PM, a designer, or a reviewer can judge a chart of what they see. They cannot ## Setup - Java 11 or later and `tla2tools.jar` from the [TLA+ releases](https://github.com/tlaplus/tlaplus/releases). Set `TLA2TOOLS` to the jar's path. -- Node. The scripts in `scripts/` next to this file have no dependencies. +- Node. The scripts in `scripts/` next to this file have no dependencies. `node --test scripts/test/statechart.test.mjs` checks them, and runs the TLC tests too when `TLA2TOOLS` is set. - To render, `npx -y @mermaid-js/mermaid-cli -i chart.mmd -o chart.png -s 2`. -Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. With `-continue`, TLC keeps going past a broken rule and exits 0, so read its output, not its exit code. +Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. ```bash tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -continue "$@"; } ``` +With `-continue`, TLC exits 0 after a broken rule, and its output still ends with "Model checking completed. No error has been found." So count the `Error: Invariant` lines. A nonzero exit, or no "Model checking completed" line, means the run failed or stopped early. Read the output to find why. `tlc-to-chart.mjs` refuses such a file. + ## Model to chart -1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. +1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen, or it names a fact they care about. When the harm is off screen, such as a second charge while the screen still says "Paying", put the fact in the name ("Paying, charged twice"). Otherwise the bug's path looks like a normal path. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. 2. **Write the reader's rules as invariants.** One sentence the reader agrees to is one invariant, with the sentence as its comment. For "once everything settles", define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the reader does not take. Never weaken a rule to make the chart pass. A broken rule is the finding. 3. **Write the mapping module.** It is an ES module that `tlc-to-chart.mjs` imports. ```js + // TLA+ actions the reader takes. facts.settled and --cover need them. export const readerActions = ["EditNote", "OpenApp"]; - // Reader words for one model state. facts.settled is true when only reader actions can happen. + // Reader words for one model state. export function state(v, facts) {} - // Edge label in reader words. Return "" to hide a step. + // Edge label in reader words. export function step(action, before, after) {} // Why a bad state is bad, in code terms. export function cause(v, facts, invariant) {} @@ -45,20 +48,25 @@ tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -contin export function detail(v, facts) {} ``` + - `v`, `before`, and `after` map each variable name to its value. Strings, numbers, and booleans become their JavaScript kind. Sequences, tuples, and sets become arrays, indexed from 0. Records become objects. A function `(a :> 1 @@ b :> 2)` becomes an object keyed by its domain. + - `facts.enabled` is the set of actions the state enables, including steps that change no variable. `facts.settled` is true when every enabled action is a reader action, so nothing else happens until the reader acts. + - `invariant` is the name of the broken rule. + - A step that leaves the reader in the same state is not drawn, whatever `step` returns. A reader action is the exception on a bug path, because the reader did it. When `step` returns `""`, the edge has no label in the overview, and a numbered bug step shows the TLA+ action name. + Counterexamples that break the same rule with the same `cause` are one bug. Two bugs that end on the same screen through different code paths stay apart only if `cause` names the code path, such as "the badge read the cache before the server answered". -4. **Run TLC.** One run dumps the state graph. Then run once per rule, because TLC reports only the first rule a state breaks. +4. **Run TLC.** Keep the rules out of `Spec.cfg`. TLC reports only the first rule a state breaks, so each rule gets its own run, with one `INVARIANT` line added to `Spec.cfg`. One more run dumps the state graph. ```bash tlc -dump dot,actionlabels Spec.dot -config Spec.cfg Spec.tla > Spec.out - for rule in NoStaleBadge NoLostEdit; do - { grep -v '^INVARIANT' Spec.cfg; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" + for rule in TypeOK NoStaleBadge NoLostEdit; do + { cat Spec.cfg; echo; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" tlc -config "Spec-$rule.cfg" Spec.tla > "Spec-$rule.out" - echo "$rule: $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" + echo "$rule: exit $?, $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" done ``` - If an open change fixes some of it, model the change as a second spec (`SpecFixed.tla`) and run the same commands on it. + If an open change fixes some of it, model the change as a second spec, `SpecFixed.tla` with `MODULE SpecFixed`. Copy `Spec.cfg` to `SpecFixed.cfg`, and run the same commands with `SpecFixed` in place of `Spec`. 5. **Draw the reader's chart.** @@ -71,44 +79,47 @@ tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -contin --out chart.mmd --story stories.md --summary summary.json ``` - Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Draw one chart per bug for the hand-off, and one with `--highlight 0` for the overview. + Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state, plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Bugs are numbered by fewest reader steps, then fewest model steps. Bugs that only the fixed model has come after the current bugs. Run once with `--highlight 0` to get the overview and the numbers in `summary.json`. Then draw one chart per bug for the hand-off. - Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and redraw. + Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and draw again. -6. **Show one bug's mechanism.** `--view developer --path-only --highlight N` draws bug N through the `detail` states. Do not draw a whole developer chart. On one model it had twelve times the states of the reader's chart. `--view layered` puts the developer states inside the reader states, for a reviewer who wants both on one page. +6. **Show one bug's mechanism.** Add `--view developer --path-only --highlight N` to the step 5 command to draw bug N through the `detail` states. Use `--view layered --path-only --highlight N` to put the developer states inside the reader states, for a reviewer who wants both on one page. Always use `--path-only` with these views. Without it, one model gave twelve times the states of the reader's chart. 7. **Reproduce every red path in the real code.** The model can be wrong. Replay each bug's story from `stories.md` as a test or a live repro against the real code, one step per model action. A bug you cannot reproduce is a modeling error until shown otherwise. Fix the model, not the rule. Report which bugs reproduced. ## Chart to model -1. **Write the chart** in Mermaid `stateDiagram-v2`. Split a top-level composite with `--` into regions. Each region is one variable. Each transition label is one action, and regions with the same label move together. Write each rule as a comment line. +1. **Write the chart** in Mermaid `stateDiagram-v2`. To make parallel regions, split one top-level composite with `--`. Wrap each part in a named composite, such as `state Connection { ... }`, to name its region and its variable. Each region is one variable. Each transition label is one action. An action can happen when at least one region can take that label. Every region that can take it moves, and the other regions stay where they are. An unlabeled transition is its own action. Write each rule as a comment line. ``` %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. ``` - Each name in a `never` line is a state, or a composite that stands for every state inside it. The names must come from different regions. The chart cannot hold history states. Draw "comes back to where it was" as an edge to that state. + Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, and regions below the top level. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. -2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`. +2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`, and prints the rules. `Sync.cfg` names no rule, and a comment in it lists them. Fill in `readerActions` in `Sync.map.mjs` with the actions the reader takes. -3. **Check the agreed chart.** Run step 4 of model to chart on `Sync`, then draw and compare. +3. **Check the agreed chart.** Run step 4 of model to chart on `Sync` with the rules it printed. Then draw the chart for comparison and compare. ```bash - node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out --highlight 0 --out drawn.mmd + node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out \ + --highlight 0 --all-labels --out drawn.mmd node scripts/compare-charts.mjs chart.mmd drawn.mmd ``` - `compare-charts.mjs` prints `same` when the drawn chart has the same states and transitions. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. + Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses other charts and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. 4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. ## Read the chart -- Gray states work today. Red states are still wrong, because the checks still reach a first bad state there with the fix in. Green states are fixed by the open change. Without `--fixed-*`, every bad state is red. +- Gray states work today. Red states are still wrong with the fix in. Green states are fixed by the open change, or new with it. Without `--fixed-*`, every bad state is red. +- A state is bad when it breaks a rule, or when every path from the start to it passes a state that breaks one. What the reader sees after a bug is part of that bug. - A thick blue border and numbered labels mark the highlighted bug. Mermaid state diagrams cannot color an edge. -- An edge shows its two most common labels. "or …" means there are more. -- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. -- `summary.json` has the counts behind the chart. Quote it, not an estimate. +- An edge labeled "with the fix" leaves the state before the step that the fix changes, and ends where the fixed model goes instead. An edge labeled "blocked by the fix" loops on the state where the fix stops that step. An edge labeled "(with the fix)" exists only in the fixed model. +- An edge shows its two most common labels. "or …" means there are more. `--all-labels` shows them all. +- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. A bug that only the fixed model has is marked "New with the fix". +- `summary.json` has the counts behind the chart. Quote it, not an estimate. Its counterexample counts are lower than the counts from the step 4 loop. TLC reports each bad state, including the bad states after the first one on a path. `summary.json` counts only the paths that end on their first bad state. ## Hand off diff --git a/pstack/skills/statechart/scripts/chart-to-tla.mjs b/pstack/skills/statechart/scripts/chart-to-tla.mjs index 2721c88b5..d671f48ee 100644 --- a/pstack/skills/statechart/scripts/chart-to-tla.mjs +++ b/pstack/skills/statechart/scripts/chart-to-tla.mjs @@ -10,6 +10,7 @@ // A name in a never line is a state, or a composite that stands for every state inside it. // // Writes NAME.tla, NAME.cfg, and NAME.map.mjs (for tlc-to-chart.mjs) into OUT, next to the chart by default. +// NAME.cfg names no rule; a comment in it lists the rules, and each TLC run appends one INVARIANT line. import fs from "node:fs"; import path from "node:path"; import process from "node:process"; @@ -120,7 +121,7 @@ out.push("Spec == Init /\\ [][Next]_vars"); function resolve(item) { const hits = []; for (const [ri, x] of vars.entries()) { - const inside = [...x.states].filter((s) => s === item || s.startsWith(`${item}.`)); + const inside = x.states.has(item) ? [item] : [...x.states].filter((s) => s.startsWith(`${item}.`)); if (inside.length) hits.push({ ri, inside }); } if (hits.length === 0) throw new Error(`never line names "${item}", which is not a state in the chart`); @@ -142,7 +143,9 @@ for (const n of nevers) { } out.push("", "====", ""); -const cfg = ["SPECIFICATION Spec", "INVARIANT TypeOK", ...invariants.map((n) => `INVARIANT ${n}`), ""]; +// TLC reports only the first rule a state breaks, so the config names no rule and each TLC run appends one. +const rules = ["TypeOK", ...invariants]; +const cfg = ["SPECIFICATION Spec", `\\* Rules, one TLC run each: ${rules.join(" ")}`, ""]; const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); const map = [ @@ -150,6 +153,8 @@ const map = [ `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, `const LABELS = ${JSON.stringify(labels, null, "\t")};`, "", + "// The actions the reader takes. facts.settled and --cover need them. Add cause() and detail() as the spec grows.", + "export const readerActions = [];", 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', "export const step = (action) => LABELS[action] ?? action;", "", @@ -160,5 +165,5 @@ fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); console.error( - `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), ${invariants.length} invariant(s) -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, ); diff --git a/pstack/skills/statechart/scripts/compare-charts.mjs b/pstack/skills/statechart/scripts/compare-charts.mjs index b3c2df1a9..17094370e 100644 --- a/pstack/skills/statechart/scripts/compare-charts.mjs +++ b/pstack/skills/statechart/scripts/compare-charts.mjs @@ -1,79 +1,83 @@ #!/usr/bin/env node -// Compare two Mermaid stateDiagram-v2 charts: states, start states, and labeled transitions. +// Compare two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled transitions. // // node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd // // Use it to check that a spec still draws the chart the reader agreed to: EXPECTED is the agreed chart, -// ACTUAL is what tlc-to-chart.mjs drew from the spec. When EXPECTED has parallel regions and ACTUAL -// has one, ACTUAL's states split on " | " into one state per region, in EXPECTED's region order -// (the order chart-to-tla.mjs's mapping writes them). An edge label "a or b" counts as two labels, -// and a highlighted path's step numbers ("2. ") are not part of the label. -// Exits 1 when the charts differ. +// ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer view with --highlight 0 --all-labels, +// without --cover or --fixed-dot. A chart with parallel regions compares as its combined machine +// (productOf in lib.mjs), so a guard that blocks a step in only some combinations of regions shows as a +// difference. A combined state is the region states joined by " | " in region order, which is what +// chart-to-tla.mjs's mapping writes. An edge label "a or b" counts as two labels. An unlabeled edge counts +// only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only the labels. +// Exits 1 when the charts differ and 2 when ACTUAL is not drawn for comparison. import fs from "node:fs"; import process from "node:process"; -import { parseMermaid } from "./lib.mjs"; +import { parseMermaid, productOf } from "./lib.mjs"; const [expectedFile, actualFile] = process.argv.slice(2); if (!expectedFile || !actualFile) { console.error("Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"); process.exit(2); } -const expected = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; -let actual = parseMermaid(fs.readFileSync(actualFile, "utf8")).regions; -const names = Object.keys(expected); -if (names.length > 1 && Object.keys(actual).length === 1) { - const [only] = Object.values(actual); - const split = (s) => { - const parts = s.split(" | "); - if (parts.length !== names.length) throw new Error(`state "${s}" does not split into ${names.length} regions on " | "`); - return parts; - }; - actual = Object.fromEntries(names.map((n) => [n, { states: new Set(), initial: new Set(), transitions: [] }])); - for (const s of only.states) split(s).forEach((p, i) => actual[names[i]].states.add(p)); - for (const s of only.initial) split(s).forEach((p, i) => actual[names[i]].initial.add(p)); - for (const t of only.transitions) { - const [from, to] = [split(t.from), split(t.to)]; - names.forEach((n, i) => { - if (from[i] !== to[i]) actual[n].transitions.push({ from: from[i], to: to[i], label: t.label }); - }); +const machine = (regions) => { + const rs = Object.values(regions); + return rs.length === 1 ? rs[0] : productOf(regions); +}; +const expectedRegions = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; +const expected = machine(expectedRegions); +const actual = machine(parseMermaid(fs.readFileSync(actualFile, "utf8")).regions); + +const refusals = [ + [(l) => / or …$/.test(l), "an edge label ends in \" or …\"; draw ACTUAL with --all-labels"], + [(l) => /^\d+(, \d+)*\. /.test(l), "an edge label is a numbered path step; draw ACTUAL with --highlight 0"], + [(l) => /with the fix\)?$|blocked by the fix$/.test(l), "an edge comes from the fixed model; draw ACTUAL without --fixed-dot"], +]; +for (const [bad, why] of refusals) { + if (actual.transitions.some((t) => bad(t.label))) { + console.error(`${actualFile}: ${why}`); + process.exit(2); } } -const edgeSet = (r) => { +const edgeSet = (m) => { + const labeled = new Set(); const out = new Set(); - for (const t of r.transitions) { - const labels = t.label.replace(/^\d+(, \d+)*\. /, "").replace(/ or …$/, "").split(" or "); - for (const l of labels) out.add(`${t.from} --> ${t.to}${l ? ` : ${l}` : ""}`); + for (const t of m.transitions) { + if (!t.label) continue; + labeled.add(`${t.from} --> ${t.to}`); + for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); } + for (const t of m.transitions) if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); return out; }; const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); +// A region state that no combination of the agreed chart's own steps reaches can never be drawn. +const unreached = []; +if (Object.keys(expectedRegions).length > 1) { + Object.entries(expectedRegions).forEach(([name, r], ri) => { + const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); + for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); + }); +} + +const rows = [ + ["region states the agreed chart's own steps never reach", unreached.sort()], + ["states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", minus(expected.states, actual.states)], + ["states only in ACTUAL", minus(actual.states, expected.states)], + ["start states only in EXPECTED", minus(expected.initial, actual.initial)], + ["start states only in ACTUAL", minus(actual.initial, expected.initial)], + ["transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", minus(edgeSet(expected), edgeSet(actual))], + ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], +].filter(([, xs]) => xs.length > 0); + let differences = 0; -for (const name of new Set([...names, ...Object.keys(actual)])) { - const e = expected[name]; - const a = actual[name]; - if (!e || !a) { - console.log(`Region ${name}: only in ${e ? "EXPECTED" : "ACTUAL"}`); - differences++; - continue; - } - const rows = [ - ["states only in EXPECTED (no path from the start reaches them, or the spec renamed them)", minus(e.states, a.states)], - ["states only in ACTUAL", minus(a.states, e.states)], - ["start states only in EXPECTED", minus(e.initial, a.initial)], - ["start states only in ACTUAL", minus(a.initial, e.initial)], - ["transitions only in EXPECTED", minus(edgeSet(e), edgeSet(a))], - ["transitions only in ACTUAL", minus(edgeSet(a), edgeSet(e))], - ].filter(([, xs]) => xs.length > 0); - if (rows.length === 0) continue; - console.log(`Region ${name}:`); - for (const [title, xs] of rows) { - console.log(` ${title}:`); - for (const x of xs) console.log(` ${x}`); - differences += xs.length; - } +for (const [title, xs] of rows) { + console.log(`${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; } console.log(differences === 0 ? "same" : `${differences} difference(s)`); process.exit(differences === 0 ? 0 : 1); diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index 03b569877..b620e2d87 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -130,55 +130,85 @@ function unescapeDot(s) { return out; } +// Returns { nodes, edges, loops }. loops are steps that change no variable; they count as enabled +// actions but are not transitions. export function parseDot(text) { const nodes = new Map(); const edges = []; + const loops = []; + const seen = new Set(); const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); for (const line of text.split("\n")) { let m = edgeRe.exec(line); if (m) { - if (m[1] !== m[2]) edges.push({ from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }); + const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; + const id = `${e.from} ${e.to} ${e.action}`; + if (seen.has(id)) continue; + seen.add(id); + (e.from === e.to ? loops : edges).push(e); continue; } m = nodeRe.exec(line); if (m) nodes.set(m[1], { id: m[1], vars: parseState(unescapeDot(m[2])), init: /style\s*=\s*filled/.test(m[3]) }); } if (nodes.size === 0) throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); - const missing = edges.find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); if (missing) throw new Error(`transition ${missing.from} -> ${missing.to} names a state the file does not define`); - return { nodes, edges }; + if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) throw new Error("the graph has no action names; dump it with TLC -dump dot,actionlabels "); + return { nodes, edges, loops }; } // ---------- TLC error traces (one per violation; many with -continue) ---------- +// A trace that follows an error other than a broken invariant has invariant null. export function parseTraces(text) { const traces = []; let invariant = null; let current = null; const lines = text.split("\n"); + const stateAt = (i) => { + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + return [parseState(body.join("\n")), i]; + }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; - const inv = /^Error: Invariant (\S+) is violated/.exec(line); + const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); if (inv) { invariant = inv[1]; + if (inv[2]) { + const [vars, end] = stateAt(i); + traces.push({ invariant, steps: [{ action: null, vars }] }); + i = end; + } continue; } + if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) invariant = null; const head = /^State (\d+): <([^\s>(]+)/.exec(line); if (!head) continue; - const body = []; - while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); - const step = { action: head[2] === "Initial" ? null : head[2], vars: parseState(body.join("\n")) }; + const [vars, end] = stateAt(i); + i = end; if (head[1] === "1") { current = { invariant, steps: [] }; traces.push(current); } - current?.steps.push(step); + current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); } return traces; } +// Throws unless TLC finished and every error it printed is a broken invariant. With -continue, TLC +// prints "Model checking completed" even after broken invariants, and not when it stopped early. +export function checkTlcOutput(text, file) { + const other = text + .split("\n") + .find((l) => l.startsWith("Error: ") && !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l)); + if (other) throw new Error(`${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`); + if (!/^Model checking completed\./m.test(text)) throw new Error(`${file}: TLC did not finish, so its counterexamples are not all there; read the file`); +} + // ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- export function wrap(text, width = 26) { @@ -195,7 +225,15 @@ export function wrap(text, width = 26) { return rows.join("
"); } -const unwrap = (s) => s.replace(//g, " ").replace(/\s+/g, " ").trim(); +const unwrap = (s) => + s + .replace(//g, " ") + .replace(/#(quot|34);/g, '"') + .replace(/\s+/g, " ") + .trim(); + +// Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. +export const quote = (s) => String(s).replaceAll('"', "#quot;"); // The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; @@ -212,8 +250,20 @@ export function parseMermaid(text) { const stack = [root]; const part = () => stack[stack.length - 1].parts.at(-1); const display = (id) => names.get(id) ?? id; + // As in Mermaid, a state that only transitions name belongs to the part where it first appears. + const implicit = new Map(); + const declare = (id) => { + const prev = implicit.get(id); + if (prev) prev.children.splice(prev.children.findIndex((c) => c.name === id), 1); + implicit.set(id, null); + }; + const mention = (id) => { + if (id === "[*]" || implicit.has(id)) return; + part().children.push({ name: id, leaf: true }); + implicit.set(id, part()); + }; for (const raw of text.split("\n")) { - const line = raw.trim(); + const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); if (never) { nevers.push({ name: never[1], states: never[2].split("&").map((s) => s.trim()), sentence: never[3] ?? "" }); @@ -226,7 +276,8 @@ export function parseMermaid(text) { if (plain) m = [line, null, plain[1], plain[2]]; } if (m) { - if (m[1] !== null) names.set(m[2], unwrap(m[1])); + if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); + declare(m[2]); if (m[3]) { const node = { name: m[2], parts: [newPart()] }; part().children.push(node); @@ -245,12 +296,18 @@ export function parseMermaid(text) { } m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); if (m) { + if (m[2] === "[*]" && m[1] !== "[*]") throw new Error(`cannot read final state in "${line}"; draw the step to the state the reader sees next`); + mention(m[1]); + mention(m[2]); part().transitions.push({ from: m[1], to: m[2], label: m[3] ? unwrap(m[3]) : "" }); continue; } m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); if (m) { - names.set(m[1], unwrap(m[2])); + // Mermaid shows every description line; the first one names the state. + if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); + if (implicit.get(m[1]) === null) continue; + declare(m[1]); part().children.push({ name: m[1], leaf: true }); continue; } @@ -295,6 +352,7 @@ export function parseMermaid(text) { } const full = `${prefix}${display(c.name)}`; if (c.leaf) { + if (out.states.has(full)) throw new Error(`two states have the caption "${full}"; give each its own caption`); out.states.add(full); info.set(c.name, { all: [full], initial: [full], depth }); all.push(full); @@ -350,6 +408,64 @@ export function parseMermaid(text) { return { regions, nevers }; } +// The combined machine of a chart's regions, as chart-to-tla.mjs generates it. A labeled step can +// happen when at least one region can take it; every region that can take it moves, the others stay. +// An unlabeled transition moves its own region only. A combined state is the region states joined +// by " | " in region order. Only states reachable from the start are kept, and steps that change +// nothing are dropped, as TLC's state graph drops them. +export function productOf(regions) { + const rs = Object.values(regions); + const join = (parts) => parts.join(" | "); + const byLabel = new Map(); + const unlabeled = []; + rs.forEach((r, ri) => { + for (const t of r.transitions) { + if (!t.label) { + unlabeled.push({ ri, from: t.from, to: t.to }); + continue; + } + if (!byLabel.has(t.label)) byLabel.set(t.label, rs.map(() => new Map())); + const moves = byLabel.get(t.label)[ri]; + if (!moves.has(t.from)) moves.set(t.from, new Set()); + moves.get(t.from).add(t.to); + } + }); + let starts = [[]]; + for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); + const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; + const seen = new Set(starts.map(join)); + const queue = [...starts]; + const step = (from, to, label) => { + const [a, b] = [join(from), join(to)]; + if (a === b) return; + out.transitions.push({ from: a, to: b, label }); + if (!seen.has(b)) { + seen.add(b); + queue.push(to); + } + }; + while (queue.length) { + const cur = queue.shift(); + out.states.add(join(cur)); + for (const u of unlabeled) { + if (cur[u.ri] !== u.from) continue; + const next = [...cur]; + next[u.ri] = u.to; + step(cur, next, ""); + } + for (const [label, perRegion] of byLabel) { + if (!perRegion.some((moves, ri) => moves.has(cur[ri]))) continue; + let nexts = [[]]; + perRegion.forEach((moves, ri) => { + const tos = moves.has(cur[ri]) ? [...moves.get(cur[ri])] : [cur[ri]]; + nexts = nexts.flatMap((p) => tos.map((s) => [...p, s])); + }); + for (const next of nexts) step(cur, next, label); + } + } + return out; +} + export const STATUS_CLASSES = [ "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index d73cc9da9..54fb161f9 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -7,7 +7,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; +import { checkTlcOutput, parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const scripts = dirname(here); @@ -74,6 +74,38 @@ test("the parser refuses charts it cannot read", () => { () => parseMermaid("stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A"), /below the top level/, ); + assert.throws(() => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), /final state/); + assert.throws( + () => parseMermaid('stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go'), + /two states have the caption "Something went wrong"/, + ); +}); + +test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + classDef wrong fill:#fdd + N : Nothing chosen + N : press Upload to start + [*] --> N + N:::wrong --> Done : you finish + Done --> Moving:::wrong + state A { + [*] --> X + X --> Y : go + } + Moving --> A : start + Y --> Z : next + A --> N : cancel`); + assert.deepEqual([...regions.Main.states].sort(), ["A.X", "A.Y", "Done", "Moving", "Nothing chosen", "Z"]); + assert.deepEqual(edges(regions.Main), [ + "A.X -> A.Y : go", + "A.X -> Nothing chosen : cancel", + "A.Y -> Nothing chosen : cancel", + "A.Y -> Z : next", + "Done -> Moving : ", + "Moving -> A.X : start", + "Nothing chosen -> Done : you finish", + ]); }); test("TLC values, states, graphs, and traces parse", () => { @@ -83,11 +115,14 @@ test("TLC values, states, graphs, and traces parse", () => { 1 [label="/\\\\ x = 0",style = filled] 2 [label="/\\\\ x = 1"] 1 -> 2 [label="Inc",color="black",fontcolor="black"]; +1 -> 2 [label="Inc",color="black",fontcolor="black"]; 2 -> 2 [label="Stay"]; }`); assert.equal(graph.nodes.get("1").init, true); assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); + assert.throws(() => parseDot('strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}'), /no action names/); const [trace] = parseTraces(`Error: Invariant Small is violated. Error: The behavior up to this point is: State 1: @@ -100,6 +135,73 @@ x = 1 assert.deepEqual(trace.steps, [{ action: null, vars: { x: 0 } }, { action: "Inc", vars: { x: 1 } }]); }); +const done = "Model checking completed. No error has been found.\n"; +const evaluationError = `Error: Invariant NoLost is violated. +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Lost" + +Error: TLC threw an unexpected exception. +This was probably caused by an error in the spec or model. + +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Saving" + +3 states generated, 3 distinct states found, 0 states left on queue. +`; + +test("a rule broken by a start state is a counterexample of one state", () => { + const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: +/\\ badge = "Synced" +/\\ conn = "Offline" + +${done}`; + assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.deepEqual(parseTraces(text), [{ invariant: "NeverSyncedWhileOffline", steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }] }]); +}); + +test("TLC output from a run that failed or stopped early is refused", () => { + assert.doesNotThrow(() => checkTlcOutput(`Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, "ok.out")); + assert.throws(() => checkTlcOutput("Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", "cfg.out"), /cfg\.out: TLC stopped on an error that is not a broken rule/); + assert.throws(() => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), /not a broken rule/); + assert.throws(() => checkTlcOutput(evaluationError, "eval.out"), /TLC threw an unexpected exception/); + assert.throws(() => checkTlcOutput("Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", "stopped.out"), /did not finish/); + assert.deepEqual( + parseTraces(evaluationError).map((t) => t.invariant), + ["NoLost", null], + ); +}); + +test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "save.dot"), + `strict digraph DiskGraph { +1 [label="/\\\\ s = \\"Idle\\"",style = filled] +2 [label="/\\\\ s = \\"Saving\\""] +1 -> 2 [label="Tap"]; +2 -> 2 [label="Retry"]; +}`, + ); + writeFileSync( + join(dir, "save.map.mjs"), + `export const readerActions = ["Tap"]; +export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving"); +export const step = (action) => ({ Tap: "you tap Save" })[action] ?? ""; +`, + ); + const r = node("tlc-to-chart.mjs", ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), ['Badge says "Synced" -> Saving : you tap Save']); +}); + test("chart-to-tla writes one action per label and one invariant per never line", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); @@ -109,7 +211,8 @@ test("chart-to-tla writes one action per label and one invariant per never line" assert.match(spec, /^VARIABLES connection, syncBadge$/m); assert.match(spec, /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m); assert.match(spec, /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m); - assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n"); + assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\n\\* Rules, one TLC run each: TypeOK NeverSyncedWhileOffline\n"); + assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); }); test("chart-to-tla refuses a never line that names no state", () => { @@ -120,17 +223,105 @@ test("chart-to-tla refuses a never line that names no state", () => { assert.match(r.stderr, /names "C", which is not a state/); }); +test("a never line names a state exactly, or every state inside a composite", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + `stateDiagram-v2 + state "Saving" as S + state "Saving..." as S2 + state Busy { + [*] --> Checking + Checking --> Sending : the check passes + } + [*] --> S + S --> S2 : you wait + S2 --> Busy : you send + Busy --> S : you cancel + %% never NoSaving: Saving -- You never see Saving. + %% never NoBusy: Busy -- You are never busy. +`, + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "C.tla"), "utf8"); + assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); + assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); + assert.match(readFileSync(join(dir, "C.map.mjs"), "utf8"), /^export const readerActions = \[\];$/m); +}); + +const door = `stateDiagram-v2 + state Door { + [*] --> Locked + Locked --> Unlocked : tap unlock + Unlocked --> Locked : tap lock + -- + [*] --> Closed + Closed --> Open : open the door + Open --> Closed : close the door + } + [*] --> Door`; +const flatDoor = (extra = []) => + [ + "stateDiagram-v2", + ' state "Locked | Closed" as L_C', + ' state "Locked | Open" as L_O', + ' state "Unlocked | Closed" as U_C', + ' state "Unlocked | Open" as U_O', + " [*] --> L_C", + " L_C --> U_C : tap unlock", + " U_C --> L_C : tap lock", + " L_O --> U_O : tap unlock", + " L_C --> L_O : open the door", + " L_O --> L_C : close the door", + " U_C --> U_O : open the door", + " U_O --> U_C : close the door", + ...extra, + ].join("\n"); + +test("compare reads parallel regions as one machine, so a guard across regions shows", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + writeFileSync(join(dir, "guarded.mmd"), flatDoor()); + writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); + const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); + assert.equal(differ.status, 1); + assert.match(differ.stdout, /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/); + const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); + assert.equal(same.stdout.trim(), "same", same.stdout); +}); + +test("compare refuses an ACTUAL chart that is not drawn for comparison", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + for (const [label, why] of [ + ["tap lock or …", /--all-labels/], + ["1. tap lock", /--highlight 0/], + ["tap lock (with the fix)", /without --fixed-dot/], + ["tap lock, blocked by the fix", /without --fixed-dot/], + ]) { + writeFileSync(join(dir, "actual.mmd"), flatDoor([` U_O --> L_O : ${label}`])); + const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); + assert.equal(r.status, 2, label); + assert.match(r.stderr, why); + } +}); + const jar = process.env.TLA2TOOLS; const java = process.env.JAVA ?? "java"; const needsTlc = { skip: jar ? false : "set TLA2TOOLS to run TLC" }; -function tlc(dir, name) { +// Without a rule, dump NAME.dot. With a rule, check it alone in NAME-RULE.out, as the SKILL.md loop does. +function tlc(dir, name, rule) { + const base = rule ? `${name}-${rule}` : name; + if (rule) writeFileSync(join(dir, `${base}.cfg`), `${readFileSync(join(dir, `${name}.cfg`), "utf8")}\nINVARIANT ${rule}\n`); + const dump = rule ? [] : ["-dump", "dot,actionlabels", `${name}.dot`]; const r = spawnSync( java, - ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", "-dump", "dot,actionlabels", `${name}.dot`, "-config", `${name}.cfg`, `${name}.tla`], + ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", ...dump, "-config", `${base}.cfg`, `${name}.tla`], { cwd: dir, encoding: "utf8" }, ); - writeFileSync(join(dir, `${name}.out`), r.stdout); + writeFileSync(join(dir, `${base}.out`), r.stdout); return r; } @@ -139,6 +330,9 @@ test("chart to TLA+ to chart gives back the same chart", needsTlc, () => { writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); assert.equal(node("chart-to-tla.mjs", ["upload.mmd", "--module", "Upload"], dir).status, 0); assert.match(tlc(dir, "Upload").stdout, /Model checking completed\. No error has been found\./); + const typeOk = tlc(dir, "Upload", "TypeOK"); + assert.equal(typeOk.status, 0, typeOk.stdout); + assert.doesNotMatch(typeOk.stdout, /^Error:/m); const drawn = node("tlc-to-chart.mjs", ["--dot", "Upload.dot", "--map", "Upload.map.mjs", "--highlight", "0", "--out", "again.mmd"], dir); assert.equal(drawn.status, 0, drawn.stderr); const same = node("compare-charts.mjs", ["upload.mmd", "again.mmd"], dir); @@ -149,17 +343,22 @@ test("a never line finds the design bug, and the fixed model marks it fixed", ne const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); assert.equal(node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir).status, 0); - // With -continue, TLC exits 0 even when it finds a violation. - assert.match(tlc(dir, "Sync").stdout, /Invariant NeverSyncedWhileOffline is violated/); + const rule = "NeverSyncedWhileOffline"; + tlc(dir, "Sync"); + // With -continue, TLC exits 0 and still prints "Model checking completed" after a violation. + const broken = tlc(dir, "Sync", rule); + assert.equal(broken.status, 0); + assert.match(broken.stdout, /Invariant NeverSyncedWhileOffline is violated[^]*Model checking completed\. No error has been found\./); const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); const guarded = spec.replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n').replace("MODULE Sync", "MODULE SyncFixed"); assert.notEqual(guarded, spec); writeFileSync(join(dir, "SyncFixed.tla"), guarded); writeFileSync(join(dir, "SyncFixed.cfg"), readFileSync(join(dir, "Sync.cfg"))); - assert.doesNotMatch(tlc(dir, "SyncFixed").stdout, /is violated/); + tlc(dir, "SyncFixed"); + assert.doesNotMatch(tlc(dir, "SyncFixed", rule).stdout, /is violated/); - const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", "Sync.out", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; + const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", `Sync-${rule}.out`, "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); const before = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); assert.deepEqual(before.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 2]]); @@ -167,10 +366,143 @@ test("a never line finds the design bug, and the fixed model marks it fixed", ne assert.match(readFileSync(join(dir, "story.md"), "utf8"), /1\. the network drops\. Now: Offline \| Waiting to sync\n2\. the server confirms\. Now: Offline \| Synced/); assert.match(readFileSync(join(dir, "chart.mmd"), "utf8"), /: 2\. the server confirms\n/); - const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", "SyncFixed.out"]; + const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", `SyncFixed-${rule}.out`]; assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); const after = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); assert.deepEqual(after.chartStatus, { works: 3, wrong: 0, fixed: 1 }); assert.equal(after.classes[0].fix, "Offline | Waiting to sync"); assert.match(readFileSync(join(dir, "story.md"), "utf8"), /`TheServerConfirms`\) cannot happen/); }); + +const pay = (name, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges +vars == <> +Init == screen = "Cart" /\\ charges = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED charges +Charge == ${charge} +Refund == charges = 2 /\\ charges' = 1 /\\ screen' = "Refunded" +Next == Pay \\/ Charge \\/ Refund +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const payMap = `export const readerActions = ["Pay"]; +export const state = (v) => v.screen + ["", ", charged", ", charged twice"][v.charges]; +export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you", Refund: "the bank refunds you" })[action] ?? ""; +export const cause = () => "each charge request charges again"; +`; + +test("a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; + writeFileSync(join(dir, "Pay.tla"), pay("Pay", `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`)); + writeFileSync( + join(dir, "PayFixed.tla"), + pay("PayFixed", `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`), + ); + writeFileSync(join(dir, "Pay.cfg"), cfg); + writeFileSync(join(dir, "PayFixed.cfg"), cfg); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); + + const statusOf = (chart) => { + const names = new Map([...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [m[2], m[1].replace(//g, " ")])); + return Object.fromEntries([...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]])); + }; + const args = ["--dot", "Pay.dot", "--map", "pay.map.mjs", "--traces", "Pay.out", "--highlight", "0", "--out", "chart.mmd"]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { + Cart: "works", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "wrong", + "Refunded, charged": "wrong", + }); + + const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); + assert.deepEqual(statusOf(chart), { + Cart: "works", + "Paid, charged": "fixed", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "fixed", + "Refunded, charged": "fixed", + }); + const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => t.label.endsWith("with the fix")); + assert.deepEqual( + fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), + ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], + ); +}); + +test("a rule broken by a start state marks that state wrong", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Start.tla"), + `---- MODULE Start ---- +VARIABLES conn, badge +vars == <> +Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced" +Drop == conn = "Online" /\\ conn' = "Offline" /\\ badge' = "Waiting" +Next == Drop +Spec == Init /\\ [][Next]_vars +NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced") +==== +`, + ); + writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); + writeFileSync(join(dir, "start.map.mjs"), 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n'); + assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); + const args = ["--dot", "Start.dot", "--map", "start.map.mjs", "--traces", "Start.out", "--summary", "summary.json", "--out", "chart.mmd"]; + const r = node("tlc-to-chart.mjs", args, dir); + assert.equal(r.status, 0, r.stderr); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(summary.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 0]]); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); +}); + +const edit = (name, fixed) => `---- MODULE ${name} ---- +VARIABLES s, flashed +vars == <> +Init == s = "Idle" /\\ flashed = FALSE +Tap == s = "Idle" /\\ s' = "Saving" /\\ UNCHANGED flashed +Lose == s = "Saving" /\\ s' = "Lost edit" /\\ UNCHANGED flashed +Flash == s = "Saving" /\\ s' = "Flash" /\\ flashed' = ${fixed ? "TRUE" : "FALSE"} +Back == s \\in {"Lost edit", "Flash"} /\\ s' = "Idle" /\\ flashed' = FALSE +Next == Tap \\/ Flash \\/ Back${fixed ? "" : " \\/ Lose"} +Spec == Init /\\ [][Next]_vars +NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed) +==== +`; + +test("a bug that only the fixed model has is numbered after the current bugs and drawn wrong", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; + for (const [name, fixed] of [["Edit", false], ["EditFixed", true]]) { + writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); + writeFileSync(join(dir, `${name}.cfg`), cfg); + assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); + } + writeFileSync( + join(dir, "edit.map.mjs"), + 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', + ); + const args = ["--dot", "Edit.dot", "--map", "edit.map.mjs", "--traces", "Edit.out", "--fixed-dot", "EditFixed.dot", "--fixed-traces", "EditFixed.out"]; + const r = node("tlc-to-chart.mjs", [...args, "--cover", "--highlight", "0", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual(summary.classes.map((c) => [c.n, c.end, c.fix]), [ + [1, "Lost edit", "Saving"], + [2, "Flash", "new with the fix"], + ]); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); + const two = node("tlc-to-chart.mjs", [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], dir); + assert.equal(two.status, 0, two.stderr); + assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); +}); diff --git a/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/pstack/skills/statechart/scripts/tlc-to-chart.mjs index 8cd315a90..0a3ffdb42 100644 --- a/pstack/skills/statechart/scripts/tlc-to-chart.mjs +++ b/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -10,13 +10,16 @@ // readerActions (the actions the reader takes). // facts.enabled is the set of actions the state enables; facts.settled is true when // only reader actions are enabled, so nothing else happens until the reader acts. -// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state) +// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state). +// A file from a TLC run that stopped early or hit an error other than a broken rule is refused. // --fixed-dot FILE state graph of the model with the open fix; counterexamples replay against it -// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still wrong +// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still +// wrong, and adds the bugs that only the fixed model has after the current model's bugs // --view NAME customer (state labels), developer (detail labels), or layered (detail inside state) // --cover keep only the shortest path to each settled reader state, plus counterexample paths // --highlight N draw counterexample class N as a numbered path (default 1; 0 for none) // --path-only draw only the highlighted path +// --all-labels write every label on an edge (default: the two most common, then " or …") // --title TEXT chart title // --out FILE Mermaid output (default stdout) // --story FILE reader stories, one per counterexample class @@ -25,7 +28,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { LEGEND_ID, parseDot, parseTraces, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; +import { LEGEND_ID, checkTlcOutput, parseDot, parseTraces, quote, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; const args = { traces: [], fixedTraces: [], view: "customer", highlight: 1 }; for (let i = 2; i < process.argv.length; i++) { @@ -39,6 +42,7 @@ for (let i = 2; i < process.argv.length; i++) { else if (a === "--view") args.view = next(); else if (a === "--highlight") args.highlight = Number(next()); else if (a === "--path-only") args.pathOnly = true; + else if (a === "--all-labels") args.allLabels = true; else if (a === "--cover") args.cover = true; else if (a === "--title") args.title = next(); else if (a === "--out") args.out = next(); @@ -93,7 +97,7 @@ function quotient(graph) { const label = stepLabel(e.action, graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars); if (label) edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); } - // Most common wording first. A chart edge shows two at most. + // Most common wording first. A chart edge shows two unless --all-labels. for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); const settled = new Set([...graph.nodes.values()].filter((n) => factsOf(n.vars).settled).map((n) => keys.get(n.id))); return { nodes, initial, edges, keys, settled }; @@ -101,7 +105,7 @@ function quotient(graph) { function enabledIndex(g) { const byId = new Map([...g.nodes.keys()].map((id) => [id, new Set()])); - for (const e of g.edges) byId.get(e.from).add(e.action); + for (const e of [...g.edges, ...g.loops]) byId.get(e.from).add(e.action); return new Map([...g.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); } function useFacts(index) { @@ -141,7 +145,7 @@ function replayFixed(trace) { if (!fixedGraph) return null; const byKey = new Map([...fixedGraph.nodes.values()].map((n) => [stateKey(n.vars), n])); const out = new Map(); - for (const e of fixedGraph.edges) { + for (const e of [...fixedGraph.edges, ...fixedGraph.loops]) { if (!out.has(e.from)) out.set(e.from, []); out.get(e.from).push(e); } @@ -166,10 +170,16 @@ function replayFixed(trace) { return { step: trace.steps.length, sameEnd: true }; } +function readTraces(files) { + return files.flatMap((f) => { + const text = fs.readFileSync(f, "utf8"); + checkTlcOutput(text, f); + return parseTraces(text); + }); +} // With -continue, TLC reports every bad state, including states after the first bad one. // Cut each trace at its first bad state so one bug counts once. -function firstBad(files) { - const raw = files.flatMap((f) => parseTraces(fs.readFileSync(f, "utf8"))); +function firstBad(raw) { const badStates = new Map(); for (const t of raw) { if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); @@ -187,38 +197,78 @@ function firstBad(files) { } return cuts; } -const traces = firstBad(args.traces); -const fixedTraces = firstBad(args.fixedTraces); +const rawTraces = readTraces(args.traces); +const rawFixedTraces = readTraces(args.fixedTraces); +const traces = firstBad(rawTraces); +const fixedTraces = firstBad(rawFixedTraces); // Classes and their shortest traces come from the customer states in every view, // so class N is the same bug and the same trace in the customer and developer charts. -const classesById = new Map(); -for (const trace of traces) { - const rp = readerPath(trace, customerKey); - const last = trace.steps[trace.steps.length - 1].vars; - const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; - const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; - const prev = classesById.get(id); - if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { - classesById.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); - } else prev.count++; +function group(cuts) { + const byId = new Map(); + for (const trace of cuts) { + const rp = readerPath(trace, customerKey); + const last = trace.steps[trace.steps.length - 1].vars; + const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = byId.get(id); + if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { + byId.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); + } else prev.count++; + } + return byId; } -const classes = [...classesById.values()].sort( - (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), -); +const byOrder = (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +const classesById = group(traces); +const classes = [...classesById.values()].sort(byOrder); for (const c of classes) { c.path = readerPath(c.trace); c.end = c.path[c.path.length - 1].key; c.fix = replayFixed(c.trace); } +// A bug of the fixed model that no current bug matches is new with the fix. It numbers after the current bugs. +useFacts(fixedIndex ?? graphIndex); +const introduced = [...group(fixedTraces).values()].filter((c) => !classesById.has(c.id)).sort(byOrder); +for (const c of introduced) { + c.path = readerPath(c.trace); + c.end = c.path[c.path.length - 1].key; + c.fix = null; + c.introduced = true; +} +useFacts(graphIndex); +classes.push(...introduced); // ---------- status ---------- -// A chart state is still wrong when the fixed model's checks still find a first bad state in it, -// and fixed when only the current model's checks do. Without --fixed-traces every bad state is wrong. +// A chart state is bad when it holds a state that breaks a rule, or when every path from the start to +// it passes such a state: what the reader sees after a bug is part of that bug. It is still wrong when +// it is bad in the fixed model, and fixed when it is bad only in the current model. +// Without --fixed-traces every bad state is wrong. const isFixed = (c) => c.fix?.key !== undefined && c.fix.key !== c.end; -const endKeys = (list) => new Set(list.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); -const badNow = endKeys(traces); +function badKeys(g, keys, raw) { + const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); + const out = new Set(raw.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); + if (!g) return out; + const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); + const next = new Map(); + for (const e of g.edges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); + const queue = [...clean]; + while (queue.length) { + for (const to of next.get(queue.shift()) ?? []) { + if (clean.has(to) || !ok(to)) continue; + clean.add(to); + queue.push(to); + } + } + const reached = new Set([...clean].map((id) => keys.get(id))); + for (const k of keys.values()) if (!reached.has(k)) out.add(k); + return out; +} +const badNow = badKeys(graph, now.keys, rawTraces); useFacts(fixedIndex ?? graphIndex); -const badFixed = args.fixedTraces.length > 0 ? endKeys(fixedTraces) : badNow; +const badFixed = args.fixedTraces.length > 0 ? badKeys(fixedGraph, fixed?.keys, rawFixedTraces) : badNow; useFacts(graphIndex); const allNodes = new Set([...now.nodes, ...(fixed?.nodes ?? [])]); const status = (k) => (badFixed.has(k) ? "wrong" : badNow.has(k) || (fixed && !now.nodes.has(k)) ? "fixed" : "works"); @@ -229,11 +279,18 @@ if (fixed) { if (!edges.has(id)) edges.set(id, { ...e, labels: new Set([...e.labels].map((l) => `${l} (with the fix)`)), fixedOnly: true }); } } +// The fix changes one model step of the counterexample. Its edge leaves the chart state before that +// step and ends where the fixed model goes instead, or stays put when the fix blocks the step. +const fixEdgeId = (c) => `${c.fix.from}${SEP}${SEP}${c.fix.key}${SEP}fix`; for (const c of classes) { - if (isFixed(c)) { - const id = `${c.end}${SEP}${SEP}${c.fix.key}`; - if (!edges.has(id)) edges.set(id, { from: c.end, to: c.fix.key, labels: new Set(["with the fix"]), fixedOnly: true }); - } + if (!isFixed(c)) continue; + const [before, after] = [c.trace.steps[c.fix.step - 1].vars, c.trace.steps[c.fix.step].vars]; + c.fix.from = keyOf(before); + const said = stepLabel(c.fix.action, before, after) || c.fix.action; + const id = fixEdgeId(c); + if (!edges.has(id)) edges.set(id, { from: c.fix.from, to: c.fix.key, labels: new Set(), fixedOnly: true, fixOf: new Set() }); + edges.get(id).labels.add(c.fix.disabled ? `${said}, blocked by the fix` : `${said}, with the fix`); + edges.get(id).fixOf.add(c); } // ---------- highlighted path ---------- @@ -279,7 +336,7 @@ if (args.cover) { if (fixed) bfs(fixed, [...fixed.settled].filter((k) => !now.nodes.has(k))); for (const c of classes) { c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); - if (isFixed(c)) keepEdges.add(`${c.end}${SEP}${SEP}${c.fix.key}`); + if (isFixed(c)) keepEdges.add(fixEdgeId(c)); } for (const id of keepEdges) { if (!edges.has(id)) { @@ -297,7 +354,7 @@ let keep = allNodes; if (args.pathOnly && chosen) keep = onPath; else if (coverEdges) { keep = new Set([...now.initial, ...(fixed?.initial ?? [])]); - for (const id of coverEdges) for (const k of id.split(`${SEP}${SEP}`)) keep.add(k); + for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); } const ids = new Map(); let counter = 0; @@ -311,7 +368,7 @@ if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); out.push("stateDiagram-v2"); for (const c of STATUS_CLASSES) out.push(` ${c}`); out.push(` state "Legend" as ${LEGEND_ID} {`); -out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed in an open PR" as legendFixed'); +out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed or new in an open PR" as legendFixed'); out.push(" }", " class legendWorks works", " class legendWrong wrong", " class legendFixed fixed"); const sorted = [...keep].sort(); if (args.view === "layered") { @@ -323,22 +380,22 @@ if (args.view === "layered") { } let g = 0; for (const [outer, members] of groups) { - out.push(` state "${wrap(outer)}" as g${g++} {`); - for (const k of members) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); + out.push(` state "${quote(wrap(outer))}" as g${g++} {`); + for (const k of members) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); out.push(" }"); } } else { - for (const k of sorted) out.push(` state "${wrap(label(k))}" as ${idOf(k)}`); + for (const k of sorted) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); } const initial = new Set([...now.initial, ...(fixed?.initial ?? [])]); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); let edgeCount = 0; for (const [id, e] of edges) { if (!keep.has(e.from) || !keep.has(e.to)) continue; - if (args.pathOnly && !pathNumbers.has(id) && !e.labels.has("with the fix")) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.fixOf?.has(chosen)) continue; if (coverEdges && !coverEdges.has(id)) continue; const all = [...e.labels]; - let text = all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + let text = args.allLabels ? all.join(" or ") : all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); const p = pathNumbers.get(id); if (p) text = `${p.n.join(", ")}. ${p.label}`; // ";" ends a Mermaid statement, even inside a transition label. @@ -356,11 +413,12 @@ const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); if (args.story) { const lines = []; classes.forEach((c, i) => { - lines.push(`## ${i + 1}. ${show(c.end)}`, ""); - lines.push(`Breaks \`${c.invariant ?? "unknown"}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); + lines.push(`## ${i + 1}. ${c.introduced ? "New with the fix: " : ""}${show(c.end)}`, ""); + lines.push(`Breaks \`${c.invariant}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); c.path.forEach((p, j) => lines.push(j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${p.label}. Now: ${show(p.key)}`)); lines.push(""); - if (!c.fix) lines.push("No fixed model given."); + if (c.introduced) lines.push("Only the fixed model has this bug. The path above is in the fixed model."); + else if (!c.fix) lines.push("No fixed model given."); else if (c.fix.sameEnd) lines.push("The fixed model follows the whole trace: the fix does not change this path."); else if (c.fix.reason) lines.push(`The fixed model cannot replay this trace: ${c.fix.reason}.`); else lines.push(`With the fix, model step ${c.fix.step} (\`${c.fix.action}\`) ${c.fix.disabled ? "cannot happen" : "goes elsewhere"}, and you see: ${show(c.fix.key)}`); @@ -383,8 +441,11 @@ const summary = { readerSteps: c.path.length - 1, modelSteps: c.trace.steps.length, counterexamples: c.count, - fix: c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, + fix: c.introduced ? "new with the fix" : c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, })), }; if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); -console.error(`chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ${classes.length} counterexample class(es)`); +console.error( + `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ` + + `${classes.length - introduced.length} bug(s)${introduced.length ? `, ${introduced.length} new with the fix` : ""}`, +); From 20c88e3c608cefef5a6b300e6934ed3c10e68dbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 03:46:39 +0000 Subject: [PATCH 3/9] Refuse a -- outside a composite in statechart charts A -- at the top level of a chart opened a second root part that the parser never read, so every region after it was dropped. chart-to-tla wrote a one-region skeleton, and compare-charts could print "same". parseMermaid now refuses the chart. A unit test covers parseMermaid, chart-to-tla, and compare-charts. Co-authored-by: lauren --- .../claude-code/skills/statechart/SKILL.md | 2 +- .../skills/statechart/scripts/lib.mjs | 1 + .../statechart/scripts/test/statechart.test.mjs | 17 +++++++++++++++++ pstack/skills/statechart/SKILL.md | 2 +- pstack/skills/statechart/scripts/lib.mjs | 1 + .../statechart/scripts/test/statechart.test.mjs | 17 +++++++++++++++++ 6 files changed, 38 insertions(+), 2 deletions(-) diff --git a/harness/pstack/claude-code/skills/statechart/SKILL.md b/harness/pstack/claude-code/skills/statechart/SKILL.md index 8d2ece4f0..4b2a25054 100644 --- a/harness/pstack/claude-code/skills/statechart/SKILL.md +++ b/harness/pstack/claude-code/skills/statechart/SKILL.md @@ -95,7 +95,7 @@ With `-continue`, TLC exits 0 after a broken rule, and its output still ends wit %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. ``` - Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, and regions below the top level. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. + Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, regions below the top level, and a `--` outside a composite. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. 2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`, and prints the rules. `Sync.cfg` names no rule, and a comment in it lists them. Fill in `readerActions` in `Sync.map.mjs` with the actions the reader takes. diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index b620e2d87..c1164ccf8 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -314,6 +314,7 @@ export function parseMermaid(text) { throw new Error(`cannot read chart line: ${line}`); } if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + if (root.parts.length > 1) throw new Error("the chart has -- outside a composite; to make parallel regions, split one top-level composite with --"); // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. // A composite's full name prefixes its children ("Parent.Child"). An id used only in a diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 54fb161f9..27920b5d3 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -291,6 +291,23 @@ test("compare reads parallel regions as one machine, so a guard across regions s assert.equal(same.stdout.trim(), "same", same.stdout); }); +test("a -- outside a composite is refused, not read as one region", () => { + const split = "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; + assert.throws(() => parseMermaid(split), /-- outside a composite/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "split.mmd"), split); + writeFileSync(join(dir, "door.mmd"), door); + const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); + assert.notEqual(tla.status, 0); + assert.match(tla.stderr, /-- outside a composite/); + for (const pair of [["split.mmd", "split.mmd"], ["door.mmd", "split.mmd"], ["split.mmd", "door.mmd"]]) { + const r = node("compare-charts.mjs", pair, dir); + assert.notEqual(r.status, 0, pair.join(" ")); + assert.notEqual(r.stdout.trim(), "same"); + assert.match(r.stderr, /-- outside a composite/); + } +}); + test("compare refuses an ACTUAL chart that is not drawn for comparison", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "agreed.mmd"), door); diff --git a/pstack/skills/statechart/SKILL.md b/pstack/skills/statechart/SKILL.md index 8d2ece4f0..4b2a25054 100644 --- a/pstack/skills/statechart/SKILL.md +++ b/pstack/skills/statechart/SKILL.md @@ -95,7 +95,7 @@ With `-continue`, TLC exits 0 after a broken rule, and its output still ends wit %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. ``` - Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, and regions below the top level. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. + Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, regions below the top level, and a `--` outside a composite. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. 2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`, and prints the rules. `Sync.cfg` names no rule, and a comment in it lists them. Fill in `readerActions` in `Sync.map.mjs` with the actions the reader takes. diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index b620e2d87..c1164ccf8 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -314,6 +314,7 @@ export function parseMermaid(text) { throw new Error(`cannot read chart line: ${line}`); } if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + if (root.parts.length > 1) throw new Error("the chart has -- outside a composite; to make parallel regions, split one top-level composite with --"); // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. // A composite's full name prefixes its children ("Parent.Child"). An id used only in a diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 54fb161f9..27920b5d3 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -291,6 +291,23 @@ test("compare reads parallel regions as one machine, so a guard across regions s assert.equal(same.stdout.trim(), "same", same.stdout); }); +test("a -- outside a composite is refused, not read as one region", () => { + const split = "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; + assert.throws(() => parseMermaid(split), /-- outside a composite/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "split.mmd"), split); + writeFileSync(join(dir, "door.mmd"), door); + const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); + assert.notEqual(tla.status, 0); + assert.match(tla.stderr, /-- outside a composite/); + for (const pair of [["split.mmd", "split.mmd"], ["door.mmd", "split.mmd"], ["split.mmd", "door.mmd"]]) { + const r = node("compare-charts.mjs", pair, dir); + assert.notEqual(r.status, 0, pair.join(" ")); + assert.notEqual(r.stdout.trim(), "same"); + assert.match(r.stderr, /-- outside a composite/); + } +}); + test("compare refuses an ACTUAL chart that is not drawn for comparison", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "agreed.mmd"), door); From 3c5adb66ae11221204889350cf3ebbf75d95dc90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 03:58:07 +0000 Subject: [PATCH 4/9] Exit 2 when compare-charts cannot read a chart compare-charts let a read or parse error escape, so a chart it could not read exited 1, the code for charts that differ. It now prints the file and the reason to stderr and exits 2, as SKILL.md says for a refused chart. Exit 1 now means only that the charts differ. Co-authored-by: lauren --- .../pstack/claude-code/skills/statechart/SKILL.md | 2 +- .../skills/statechart/scripts/compare-charts.mjs | 14 +++++++++++--- .../statechart/scripts/test/statechart.test.mjs | 12 ++++++++---- pstack/skills/statechart/SKILL.md | 2 +- .../skills/statechart/scripts/compare-charts.mjs | 14 +++++++++++--- .../statechart/scripts/test/statechart.test.mjs | 12 ++++++++---- 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/harness/pstack/claude-code/skills/statechart/SKILL.md b/harness/pstack/claude-code/skills/statechart/SKILL.md index 4b2a25054..28ac68cc9 100644 --- a/harness/pstack/claude-code/skills/statechart/SKILL.md +++ b/harness/pstack/claude-code/skills/statechart/SKILL.md @@ -107,7 +107,7 @@ With `-continue`, TLC exits 0 after a broken rule, and its output still ends wit node scripts/compare-charts.mjs chart.mmd drawn.mmd ``` - Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses other charts and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. + Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses a chart drawn any other way, or a chart it cannot read, and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. 4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. diff --git a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs index 17094370e..3a22f7187 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs @@ -10,7 +10,7 @@ // difference. A combined state is the region states joined by " | " in region order, which is what // chart-to-tla.mjs's mapping writes. An edge label "a or b" counts as two labels. An unlabeled edge counts // only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only the labels. -// Exits 1 when the charts differ and 2 when ACTUAL is not drawn for comparison. +// Exits 1 when the charts differ, and 2 when it cannot read a chart or ACTUAL is not drawn for comparison. import fs from "node:fs"; import process from "node:process"; import { parseMermaid, productOf } from "./lib.mjs"; @@ -25,9 +25,17 @@ const machine = (regions) => { const rs = Object.values(regions); return rs.length === 1 ? rs[0] : productOf(regions); }; -const expectedRegions = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; +const read = (file) => { + try { + return parseMermaid(fs.readFileSync(file, "utf8")).regions; + } catch (e) { + console.error(`${file}: ${e.message}`); + process.exit(2); + } +}; +const expectedRegions = read(expectedFile); const expected = machine(expectedRegions); -const actual = machine(parseMermaid(fs.readFileSync(actualFile, "utf8")).regions); +const actual = machine(read(actualFile)); const refusals = [ [(l) => / or …$/.test(l), "an edge label ends in \" or …\"; draw ACTUAL with --all-labels"], diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 27920b5d3..981a9ffdb 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -302,15 +302,19 @@ test("a -- outside a composite is refused, not read as one region", () => { assert.match(tla.stderr, /-- outside a composite/); for (const pair of [["split.mmd", "split.mmd"], ["door.mmd", "split.mmd"], ["split.mmd", "door.mmd"]]) { const r = node("compare-charts.mjs", pair, dir); - assert.notEqual(r.status, 0, pair.join(" ")); - assert.notEqual(r.stdout.trim(), "same"); - assert.match(r.stderr, /-- outside a composite/); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); } }); -test("compare refuses an ACTUAL chart that is not drawn for comparison", () => { +test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "agreed.mmd"), door); + for (const pair of [["agreed.mmd", "missing.mmd"], ["missing.mmd", "agreed.mmd"]]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^missing\.mmd: ENOENT/); + } for (const [label, why] of [ ["tap lock or …", /--all-labels/], ["1. tap lock", /--highlight 0/], diff --git a/pstack/skills/statechart/SKILL.md b/pstack/skills/statechart/SKILL.md index 4b2a25054..28ac68cc9 100644 --- a/pstack/skills/statechart/SKILL.md +++ b/pstack/skills/statechart/SKILL.md @@ -107,7 +107,7 @@ With `-continue`, TLC exits 0 after a broken rule, and its output still ends wit node scripts/compare-charts.mjs chart.mmd drawn.mmd ``` - Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses other charts and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. + Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses a chart drawn any other way, or a chart it cannot read, and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. 4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. diff --git a/pstack/skills/statechart/scripts/compare-charts.mjs b/pstack/skills/statechart/scripts/compare-charts.mjs index 17094370e..3a22f7187 100644 --- a/pstack/skills/statechart/scripts/compare-charts.mjs +++ b/pstack/skills/statechart/scripts/compare-charts.mjs @@ -10,7 +10,7 @@ // difference. A combined state is the region states joined by " | " in region order, which is what // chart-to-tla.mjs's mapping writes. An edge label "a or b" counts as two labels. An unlabeled edge counts // only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only the labels. -// Exits 1 when the charts differ and 2 when ACTUAL is not drawn for comparison. +// Exits 1 when the charts differ, and 2 when it cannot read a chart or ACTUAL is not drawn for comparison. import fs from "node:fs"; import process from "node:process"; import { parseMermaid, productOf } from "./lib.mjs"; @@ -25,9 +25,17 @@ const machine = (regions) => { const rs = Object.values(regions); return rs.length === 1 ? rs[0] : productOf(regions); }; -const expectedRegions = parseMermaid(fs.readFileSync(expectedFile, "utf8")).regions; +const read = (file) => { + try { + return parseMermaid(fs.readFileSync(file, "utf8")).regions; + } catch (e) { + console.error(`${file}: ${e.message}`); + process.exit(2); + } +}; +const expectedRegions = read(expectedFile); const expected = machine(expectedRegions); -const actual = machine(parseMermaid(fs.readFileSync(actualFile, "utf8")).regions); +const actual = machine(read(actualFile)); const refusals = [ [(l) => / or …$/.test(l), "an edge label ends in \" or …\"; draw ACTUAL with --all-labels"], diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 27920b5d3..981a9ffdb 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -302,15 +302,19 @@ test("a -- outside a composite is refused, not read as one region", () => { assert.match(tla.stderr, /-- outside a composite/); for (const pair of [["split.mmd", "split.mmd"], ["door.mmd", "split.mmd"], ["split.mmd", "door.mmd"]]) { const r = node("compare-charts.mjs", pair, dir); - assert.notEqual(r.status, 0, pair.join(" ")); - assert.notEqual(r.stdout.trim(), "same"); - assert.match(r.stderr, /-- outside a composite/); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); } }); -test("compare refuses an ACTUAL chart that is not drawn for comparison", () => { +test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "agreed.mmd"), door); + for (const pair of [["agreed.mmd", "missing.mmd"], ["missing.mmd", "agreed.mmd"]]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^missing\.mmd: ENOENT/); + } for (const [label, why] of [ ["tap lock or …", /--all-labels/], ["1. tap lock", /--highlight 0/], From 2e3bb7e8bcf892d553f360bee65ca0f02d5d1ddb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 20:55:07 +0000 Subject: [PATCH 5/9] Update the statechart skill to its current version Replace the skill, its scripts, and its tests with the current version. statechart.mjs runs the whole flow with from-spec and from-chart, and its --help replaces the old Setup section. New features are a code-against-spec audit (--against), nested state boxes, parallel regions, guards, scoped charts, timer labels, and legend groups and colors chosen in the mapping. TLC comes from TLA2TOOLS and JAVA only. Without TLA2TOOLS, the scripts point to the TLA+ releases page and exit 2. Co-authored-by: lauren --- harness/pstack/claude-code/README.md | 2 +- .../docs/guide/06-verify-and-ship.md | 2 + .../claude-code/skills/poteto-help/SKILL.md | 2 +- .../claude-code/skills/statechart/SKILL.md | 126 +- .../statechart/scripts/chart-to-tla.mjs | 289 ++- .../statechart/scripts/compare-charts.mjs | 159 +- .../skills/statechart/scripts/lib.mjs | 1100 ++++++---- .../skills/statechart/scripts/statechart.mjs | 541 +++++ .../scripts/test/statechart.test.mjs | 1955 ++++++++++++++--- .../statechart/scripts/tlc-to-chart.mjs | 1598 ++++++++++---- pstack/README.md | 2 +- pstack/docs/guide/06-verify-and-ship.md | 2 + pstack/skills/poteto-help/SKILL.md | 2 +- pstack/skills/statechart/SKILL.md | 126 +- .../statechart/scripts/chart-to-tla.mjs | 289 ++- .../statechart/scripts/compare-charts.mjs | 159 +- pstack/skills/statechart/scripts/lib.mjs | 1100 ++++++---- .../skills/statechart/scripts/statechart.mjs | 541 +++++ .../scripts/test/statechart.test.mjs | 1955 ++++++++++++++--- .../statechart/scripts/tlc-to-chart.mjs | 1598 ++++++++++---- 20 files changed, 8780 insertions(+), 2768 deletions(-) create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs create mode 100644 pstack/skills/statechart/scripts/statechart.mjs diff --git a/harness/pstack/claude-code/README.md b/harness/pstack/claude-code/README.md index 47c496ed1..bae833252 100644 --- a/harness/pstack/claude-code/README.md +++ b/harness/pstack/claude-code/README.md @@ -151,7 +151,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it | [`/maintain-verification-skill`](./skills/maintain-verification-skill/SKILL.md) | your verify skill's feature map has drifted from the app. source wave + one live pass, at most one PR of proven corrections. | | [`/create-formal-verification-skill`](./skills/create-formal-verification-skill/SKILL.md) | tests can't reach the case that breaks a race, retry, state machine, parser, or unsafe code. verifies one property with the tool that fits and generates a project-local skill to recheck it on every change. | | [`/maintain-formal-verification-skill`](./skills/maintain-formal-verification-skill/SKILL.md) | code your formal verification covers has changed since it was last checked. updates the model, harness, or properties to match the code, reruns, and says whether each failure is drift in the verification or a bug in the code. | -| [`/statechart`](./skills/statechart/SKILL.md) | someone who owns the behavior needs to see it, not read a spec. draws what the user sees as a mermaid statechart generated from a tla+ model, marks each state works, still wrong, or fixed by your open pr, and draws each bug tlc finds as a numbered path. also turns an agreed chart into a tla+ skeleton. | +| [`/statechart`](./skills/statechart/SKILL.md) | someone who owns the behavior needs to see it, not read a spec. draws what the user sees as a mermaid statechart generated from a tla+ model, marks each state works, still wrong, or fixed by your open pr, and draws each bug tlc finds as a numbered path. also turns an agreed chart into a tla+ skeleton, or audits the code against an existing spec. | | [`/unslop`](./skills/unslop/SKILL.md) | you're cleaning up writing. removes AI tells. | | [`/bro`](./skills/bro/SKILL.md) | you want the last message restated in plain human language, no jargon. | | [`/technical-writing`](./skills/technical-writing/SKILL.md) | layered doc standard (Diátaxis + Google developer style + STE + Global English) for docs, RFCs, readmes, PR descriptions, commit messages. | diff --git a/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md b/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md index 83735d7e5..c432012d2 100644 --- a/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md +++ b/harness/pstack/claude-code/docs/guide/06-verify-and-ship.md @@ -127,6 +127,8 @@ A counterexample in model terms convinces you. The PM or designer who owns the b It also runs the other way. Sketch the behavior as a chart, agree on it with the reader, and the skill writes a TLA+ skeleton from it. TLC checks the agreed chart's rules before any code exists. As guards from the real code go into the spec, each place the code departs from the chart shows up as a difference for the reader to decide. +When the code already has a TLA+ spec, the skill can audit the code against it. The agent models what the code does and checks that model next to the unchanged spec. The chart marks the steps where the two differ, and each rule either one breaks is a numbered path. + ## Open the PR ```text diff --git a/harness/pstack/claude-code/skills/poteto-help/SKILL.md b/harness/pstack/claude-code/skills/poteto-help/SKILL.md index 1719eceee..b34c864e9 100644 --- a/harness/pstack/claude-code/skills/poteto-help/SKILL.md +++ b/harness/pstack/claude-code/skills/poteto-help/SKILL.md @@ -90,7 +90,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste | Bring a verification skill and its feature map back in line with the app | [`/maintain-verification-skill`](../maintain-verification-skill/SKILL.md) | | Formally verify a race, invariant, or other property that tests rarely reach | [`/create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) | | Bring a formal verification back in line after the code it covers changed, and learn whether a failure is drift or a bug | [`/maintain-formal-verification-skill`](../maintain-formal-verification-skill/SKILL.md) | -| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, or turn an agreed chart into a TLA+ skeleton | [`/statechart`](../statechart/SKILL.md) | +| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, turn an agreed chart into a TLA+ skeleton, or audit the code against its TLA+ spec | [`/statechart`](../statechart/SKILL.md) | | Vet a performance number before reporting or acting on it | [`/benchmark-checklist`](../benchmark-checklist/SKILL.md) | | Run a large or cross-cutting change, or one to review after stepping away | [`/figure-it-out`](../figure-it-out/SKILL.md) | | Keep a decision log during a run, and review it afterward | [`/show-me-your-work`](../show-me-your-work/SKILL.md) | diff --git a/harness/pstack/claude-code/skills/statechart/SKILL.md b/harness/pstack/claude-code/skills/statechart/SKILL.md index 28ac68cc9..1693be3b3 100644 --- a/harness/pstack/claude-code/skills/statechart/SKILL.md +++ b/harness/pstack/claude-code/skills/statechart/SKILL.md @@ -1,126 +1,36 @@ --- name: statechart -description: "Draw what a user sees as a Mermaid statechart generated from a TLA+ model. States are marked works, still wrong, or fixed, and each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." +description: "Draw what an app's user sees as a Mermaid statechart from a TLA+ model. States are gray if they work, red if still wrong, and green if fixed. Each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." disable-model-invocation: true --- # Statechart -A PM, a designer, or a reviewer can judge a chart of what they see. They cannot judge a TLA+ spec. This skill draws the chart from the model, so the chart and the checked model are one object. The reader's rules ("you never see X while Y") become invariants. TLC finds every path that breaks one. The chart shows each as a numbered path in the reader's words. +The chart shows what an app's user sees. A PM reads it to decide whether that behavior is right. Label each state with what is on the screen, such as "Paying". Label each step with what the user does or sees happen, such as "tap Pay" or "the charge goes through". Keep variable and action names out of labels. The scripts call this user the reader, as in `readerActions` and reader steps. -## Pick the direction - -- **A model exists, or the code is concrete enough to model.** Go model to chart. If the project has a formal verification skill, start from its spec. A spec written for one property may not hold what the reader sees. Add those variables and keep its old invariants passing. A new spec follows the "true to the code" rules in [create-formal-verification-skill](../create-formal-verification-skill/SKILL.md). -- **No model, and the reader should agree on the behavior before code exists or changes.** Go chart to model. - -## Setup - -- Java 11 or later and `tla2tools.jar` from the [TLA+ releases](https://github.com/tlaplus/tlaplus/releases). Set `TLA2TOOLS` to the jar's path. -- Node. The scripts in `scripts/` next to this file have no dependencies. `node --test scripts/test/statechart.test.mjs` checks them, and runs the TLC tests too when `TLA2TOOLS` is set. -- To render, `npx -y @mermaid-js/mermaid-cli -i chart.mmd -o chart.png -s 2`. - -Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. - -```bash -tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -continue "$@"; } -``` - -With `-continue`, TLC exits 0 after a broken rule, and its output still ends with "Model checking completed. No error has been found." So count the `Error: Invariant` lines. A nonzero exit, or no "Model checking completed" line, means the run failed or stopped early. Read the output to find why. `tlc-to-chart.mjs` refuses such a file. - -## Model to chart - -1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen, or it names a fact they care about. When the harm is off screen, such as a second charge while the screen still says "Paying", put the fact in the name ("Paying, charged twice"). Otherwise the bug's path looks like a normal path. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. - -2. **Write the reader's rules as invariants.** One sentence the reader agrees to is one invariant, with the sentence as its comment. For "once everything settles", define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the reader does not take. Never weaken a rule to make the chart pass. A broken rule is the finding. - -3. **Write the mapping module.** It is an ES module that `tlc-to-chart.mjs` imports. - - ```js - // TLA+ actions the reader takes. facts.settled and --cover need them. - export const readerActions = ["EditNote", "OpenApp"]; - // Reader words for one model state. - export function state(v, facts) {} - // Edge label in reader words. - export function step(action, before, after) {} - // Why a bad state is bad, in code terms. - export function cause(v, facts, invariant) {} - // Developer words for one model state. Needed only for --view developer or layered. - export function detail(v, facts) {} - ``` - - - `v`, `before`, and `after` map each variable name to its value. Strings, numbers, and booleans become their JavaScript kind. Sequences, tuples, and sets become arrays, indexed from 0. Records become objects. A function `(a :> 1 @@ b :> 2)` becomes an object keyed by its domain. - - `facts.enabled` is the set of actions the state enables, including steps that change no variable. `facts.settled` is true when every enabled action is a reader action, so nothing else happens until the reader acts. - - `invariant` is the name of the broken rule. - - A step that leaves the reader in the same state is not drawn, whatever `step` returns. A reader action is the exception on a bug path, because the reader did it. When `step` returns `""`, the edge has no label in the overview, and a numbered bug step shows the TLA+ action name. - - Counterexamples that break the same rule with the same `cause` are one bug. Two bugs that end on the same screen through different code paths stay apart only if `cause` names the code path, such as "the badge read the cache before the server answered". +Run `node /scripts/statechart.mjs --help` for the commands and for how to read a chart. Work in a scratch folder outside the repo. The command writes its files next to its input. -4. **Run TLC.** Keep the rules out of `Spec.cfg`. TLC reports only the first rule a state breaks, so each rule gets its own run, with one `INVARIANT` line added to `Spec.cfg`. One more run dumps the state graph. - - ```bash - tlc -dump dot,actionlabels Spec.dot -config Spec.cfg Spec.tla > Spec.out - for rule in TypeOK NoStaleBadge NoLostEdit; do - { cat Spec.cfg; echo; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" - tlc -config "Spec-$rule.cfg" Spec.tla > "Spec-$rule.out" - echo "$rule: exit $?, $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" - done - ``` - - If an open change fixes some of it, model the change as a second spec, `SpecFixed.tla` with `MODULE SpecFixed`. Copy `Spec.cfg` to `SpecFixed.cfg`, and run the same commands with `SpecFixed` in place of `Spec`. - -5. **Draw the reader's chart.** - - ```bash - node scripts/tlc-to-chart.mjs --dot Spec.dot --map map.mjs \ - --traces Spec-NoStaleBadge.out --traces Spec-NoLostEdit.out \ - --fixed-dot SpecFixed.dot \ - --fixed-traces SpecFixed-NoStaleBadge.out --fixed-traces SpecFixed-NoLostEdit.out \ - --cover --highlight 1 --title "Offline editing today" \ - --out chart.mmd --story stories.md --summary summary.json - ``` - - Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state, plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Bugs are numbered by fewest reader steps, then fewest model steps. Bugs that only the fixed model has come after the current bugs. Run once with `--highlight 0` to get the overview and the numbers in `summary.json`. Then draw one chart per bug for the hand-off. - - Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and draw again. - -6. **Show one bug's mechanism.** Add `--view developer --path-only --highlight N` to the step 5 command to draw bug N through the `detail` states. Use `--view layered --path-only --highlight N` to put the developer states inside the reader states, for a reviewer who wants both on one page. Always use `--path-only` with these views. Without it, one model gave twelve times the states of the reader's chart. - -7. **Reproduce every red path in the real code.** The model can be wrong. Replay each bug's story from `stories.md` as a test or a live repro against the real code, one step per model action. A bug you cannot reproduce is a modeling error until shown otherwise. Fix the model, not the rule. Report which bugs reproduced. - -## Chart to model - -1. **Write the chart** in Mermaid `stateDiagram-v2`. To make parallel regions, split one top-level composite with `--`. Wrap each part in a named composite, such as `state Connection { ... }`, to name its region and its variable. Each region is one variable. Each transition label is one action. An action can happen when at least one region can take that label. Every region that can take it moves, and the other regions stay where they are. An unlabeled transition is its own action. Write each rule as a comment line. - - ``` - %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. - ``` - - Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, regions below the top level, and a `--` outside a composite. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. - -2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`, and prints the rules. `Sync.cfg` names no rule, and a comment in it lists them. Fill in `readerActions` in `Sync.map.mjs` with the actions the reader takes. +## Pick the direction -3. **Check the agreed chart.** Run step 4 of model to chart on `Sync` with the rules it printed. Then draw the chart for comparison and compare. +Use `from-spec` when a TLA+ model exists or the code is concrete enough to model. Use `from-chart` when there is no model and the PM should agree on the behavior before anyone writes or changes the code. - ```bash - node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out \ - --highlight 0 --all-labels --out drawn.mmd - node scripts/compare-charts.mjs chart.mmd drawn.mmd - ``` +If the code already has a TLA+ spec, start from a copy of that spec. Add the variables the user sees, and keep the spec's existing invariants. To audit the code against that spec, make the copy model what the code does, and pass the spec unchanged as `--against`. In a new spec, tie every variable and action to code that a reviewer can open, and take its name from that code. Make each action one indivisible step in the code. - Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses a chart drawn any other way, or a chart it cannot read, and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. +## Spec to chart -4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. +1. Choose the chart's states. When the PM cares about something the screen does not show, such as a second charge while the screen still says "Paying", name the state "Paying, charged twice". Start with few states, and split a state only when the split separates two bugs. +2. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. For a rule that applies once everything settles, define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the user does not take. Report each broken rule as a bug. Never weaken a rule to make the chart pass. +3. Run `from-spec`, then fill in the mapping file it writes. Put the labels in `state` and `step`, and list the user's actions in `readerActions` by action name. When two bugs end on the same screen through different code paths, add `cause` and make it name the code path. To color the states by what the PM cares about, such as "Toast shown", return that group from `group`. The legend names the groups, and states that break a rule still get a red border. +4. If there is an open fix, model it from the code and pass that model as `--fixed`. +5. Render each chart with `--png` and look at every PNG. Check that each numbered path reads in order. If a label reads like code, fix `state` or `step` and run the command again. +6. Replay each bug's story against the real code, as a test or a live repro, one step per model action. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule. -## Read the chart +## Chart to spec -- Gray states work today. Red states are still wrong with the fix in. Green states are fixed by the open change, or new with it. Without `--fixed-*`, every bad state is red. -- A state is bad when it breaks a rule, or when every path from the start to it passes a state that breaks one. What the reader sees after a bug is part of that bug. -- A thick blue border and numbered labels mark the highlighted bug. Mermaid state diagrams cannot color an edge. -- An edge labeled "with the fix" leaves the state before the step that the fix changes, and ends where the fixed model goes instead. An edge labeled "blocked by the fix" loops on the state where the fix stops that step. An edge labeled "(with the fix)" exists only in the fixed model. -- An edge shows its two most common labels. "or …" means there are more. `--all-labels` shows them all. -- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. A bug that only the fixed model has is marked "New with the fix". -- `summary.json` has the counts behind the chart. Quote it, not an estimate. Its counterexample counts are lower than the counts from the step 4 loop. TLC reports each bad state, including the bad states after the first one on a path. `summary.json` counts only the paths that end on their first bad state. +1. Draw the chart the PM agrees to. Use one region for each thing that changes independently, such as the connection and the sync badge. Add one `%% never` line per rule, with the PM's sentence. Draw "the app closes" as an edge to the state the user sees next. +2. Run `from-chart`. If a rule breaks, the agreed chart has a design bug. Take the bug to the PM. +3. Add the real code's guards and variables to the spec that `from-chart` wrote, and run `from-chart` again. For each difference it prints, ask the PM whether the code is wrong or the chart needs a correction. ## Hand off -Give the reader the PNG of each chart and its Mermaid source. Add one line per bug with the rule it breaks, the reader's steps, whether it reproduced, and whether the open change fixes it. Attach `stories.md` and the commands that regenerate everything. Say what the model leaves out. +Give the PM the PNG and the Mermaid source of each chart. Add one line per bug with the rule it breaks, its steps as the chart numbers them, whether it reproduced, and whether the open fix fixes it. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out. diff --git a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs index d671f48ee..dacbedd94 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs @@ -1,35 +1,54 @@ #!/usr/bin/env node -// Write a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. -// -// node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT] -// -// Each region becomes one variable whose values are the region's states. -// Each transition label becomes one action. Regions that share a label move together: -// every region that can take it does, the others stay. An unlabeled transition is its own action. -// Each "%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\ B). -// A name in a never line is a state, or a composite that stands for every state inside it. -// -// Writes NAME.tla, NAME.cfg, and NAME.map.mjs (for tlc-to-chart.mjs) into OUT, next to the chart by default. -// NAME.cfg names no rule; a comment in it lists the rules, and each TLC run appends one INVARIANT line. import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { parseMermaid } from "./lib.mjs"; +const USAGE = "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"; +const HELP = `${USAGE} + +Writes a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. +statechart.mjs from-chart runs it, then checks each INVARIANT in NAME.cfg alone. + +Each region becomes one variable whose values are the region's states. Each transition label +becomes one action. Regions that share a label move together: every region that can take it does, +and the others stay. A "%% when" line after a transition is its guard: the region can take that +transition only while the other regions are in, or with "not" are not in, the states it names. An +unlabeled transition is its own action. Each +"%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\\ B). A name in a +never line is a state, or a composite that stands for every state inside it. + + --module NAME The TLA+ module name. Required. + --dir OUT The folder for the files. Default: the chart's folder. + -h, --help Print this help, and exit 0. + +Writes NAME.tla, NAME.cfg, and NAME.map.mjs into OUT, and replaces files with those names. +NAME.cfg has SPECIFICATION Spec, INVARIANT TypeOK, and one INVARIANT per never line. +NAME.map.mjs gives tlc-to-chart.mjs the reader's words: the region states joined by " | ". + +Exit codes + 0 It wrote the files. + 1 It cannot read the chart. The error says why. + 2 A bad argument.`; +const more = "Run node chart-to-tla.mjs --help for the flags."; + const args = {}; for (let i = 2; i < process.argv.length; i++) { - const a = process.argv[i]; - if (a === "--module") args.module = process.argv[++i]; - else if (a === "--dir") args.dir = process.argv[++i]; - else if (!a.startsWith("--") && !args.chart) args.chart = a; - else { - console.error(`unknown argument ${a}`); - process.exit(2); - } + const a = process.argv[i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--module") args.module = process.argv[++i]; + else if (a === "--dir") args.dir = process.argv[++i]; + else if (!a.startsWith("-") && !args.chart) args.chart = a; + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } } if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) { - console.error("Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"); - process.exit(2); + console.error(`${USAGE}\n${more}`); + process.exit(2); } const dir = args.dir ?? path.dirname(args.chart); const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); @@ -37,42 +56,62 @@ const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]); const taken = new Set(RESERVED); function ident(words, lower) { - const parts = words.normalize("NFKD").replace(/[^A-Za-z0-9]+/g, " ").trim().split(" ").filter(Boolean); - let id = parts.map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)).join("") || "Step"; - if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; - let unique = id; - for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; - taken.add(unique); - return unique; + const parts = words + .normalize("NFKD") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); + let id = + parts + .map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)) + .join("") || "Step"; + if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; + let unique = id; + for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; + taken.add(unique); + return unique; } const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; const set = (xs) => `{${[...xs].map(str).join(", ")}}`; const vars = Object.entries(regions).map(([name, r]) => { - if (r.states.size === 0) throw new Error(`region ${name} has no states`); - if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); - return { name, v: ident(name, true), ...r }; + if (r.states.size === 0) throw new Error(`region ${name} has no states`); + if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); + return { name, v: ident(name, true), ...r }; }); +// A guard reads the other regions in the state before the step. +function guardOf(g) { + if (!g) return null; + const parts = Object.entries(g.states).map(([name, states]) => { + const { v } = vars.find((x) => x.name === name); + return states.length === 1 ? `${v} = ${str(states[0])}` : `${v} \\in ${set(states)}`; + }); + return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ "); +} + // One action per label. Unlabeled transitions get one action each. const actions = []; const byLabel = new Map(); for (const [ri, r] of vars.entries()) { - for (const t of r.transitions) { - let a = t.label ? byLabel.get(t.label) : null; - if (!a) { - a = { label: t.label, moves: new Map() }; - actions.push(a); - if (t.label) byLabel.set(t.label, a); - } - if (!a.moves.has(ri)) a.moves.set(ri, []); - const moves = a.moves.get(ri); - if (!moves.some((m) => m.from === t.from && m.to === t.to)) moves.push({ from: t.from, to: t.to }); - } + for (const t of r.transitions) { + let a = t.label ? byLabel.get(t.label) : null; + if (!a) { + a = { label: t.label, moves: new Map() }; + actions.push(a); + if (t.label) byLabel.set(t.label, a); + } + if (!a.moves.has(ri)) a.moves.set(ri, []); + const moves = a.moves.get(ri); + const when = guardOf(t.guard); + if (!moves.some((m) => m.from === t.from && m.to === t.to && m.when === when)) + moves.push({ from: t.from, to: t.to, when }); + } } for (const a of actions) { - const only = a.label ? null : [...a.moves.values()][0][0]; - a.name = ident(a.label || `${only.from} to ${only.to}`, false); + const only = a.label ? null : [...a.moves.values()][0][0]; + a.name = ident(a.label || `${only.from} to ${only.to}`, false); } const out = []; @@ -80,84 +119,132 @@ const bullet = (indent, op, lines) => lines.map((l, i) => `${" ".repeat(indent)} out.push(`---- MODULE ${args.module} ----`); out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); out.push("\\* Each region is one variable and each transition label is one action."); -out.push("\\* Once you add guards and variables from the real code, this file is the source:"); -out.push("\\* draw the chart from it with tlc-to-chart.mjs and check it with compare-charts.mjs."); +out.push("\\* Once you add guards and variables from the real code, this file is the source."); +out.push( + `\\* statechart.mjs from-chart ${path.basename(args.chart)} keeps it, checks it, and compares it with the chart.`, +); out.push(""); out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`); out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`); out.push(""); -for (const x of vars) out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); +for (const x of vars) + out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); out.push(""); -out.push("TypeOK ==", ...bullet(4, "/\\", vars.map((x) => `${x.v} \\in ${x.v}States`))); +out.push( + "TypeOK ==", + ...bullet( + 4, + "/\\", + vars.map((x) => `${x.v} \\in ${x.v}States`), + ), +); out.push(""); -out.push("Init ==", ...bullet(4, "/\\", vars.map((x) => (x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`)))); +out.push( + "Init ==", + ...bullet( + 4, + "/\\", + vars.map((x) => + x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`, + ), + ), +); for (const a of actions) { - out.push(""); - if (a.label) out.push(`\\* ${a.label}`); - out.push(`${a.name} ==`); - const moving = [...a.moves.keys()]; - const conj = []; - if (moving.length > 1) conj.push(`(${moving.map((ri) => `${vars[ri].v} \\in ${set(new Set(a.moves.get(ri).map((m) => m.from)))}`).join(" \\/ ")})`); - const blocks = []; - for (const ri of moving) { - const v = vars[ri].v; - const ds = a.moves.get(ri).map((m) => `(${v} = ${str(m.from)} /\\ ${v}' = ${str(m.to)})`); - if (moving.length > 1) ds.push(`(${v} \\notin ${set(new Set(a.moves.get(ri).map((m) => m.from)))} /\\ UNCHANGED ${v})`); - blocks.push(ds); - } - const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); - for (const c of conj) out.push(` /\\ ${c}`); - for (const ds of blocks) { - out.push(` /\\ \\/ ${ds[0]}`); - for (const d of ds.slice(1)) out.push(` \\/ ${d}`); - } - if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); + out.push(""); + if (a.label) out.push(`\\* ${a.label}`); + out.push(`${a.name} ==`); + const moving = [...a.moves.keys()]; + // Whether region ri can take the action, or with `not`, cannot. + const can = (ri, not) => { + const v = vars[ri].v; + const moves = a.moves.get(ri); + if (moves.every((m) => !m.when)) + return `${v} ${not ? "\\notin" : "\\in"} ${set(new Set(moves.map((m) => m.from)))}`; + const any = moves + .map((m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""})`) + .join(" \\/ "); + return not ? `~(${any})` : any; + }; + const conj = []; + if (moving.length > 1) conj.push(`(${moving.map((ri) => can(ri, false)).join(" \\/ ")})`); + const blocks = []; + for (const ri of moving) { + const v = vars[ri].v; + const ds = a.moves + .get(ri) + .map( + (m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""} /\\ ${v}' = ${str(m.to)})`, + ); + if (moving.length > 1) ds.push(`(${can(ri, true)} /\\ UNCHANGED ${v})`); + blocks.push(ds); + } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); + for (const c of conj) out.push(` /\\ ${c}`); + for (const ds of blocks) { + out.push(` /\\ \\/ ${ds[0]}`); + for (const d of ds.slice(1)) out.push(` \\/ ${d}`); + } + if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); } out.push(""); -out.push(actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars"); +out.push( + actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars", +); out.push(""); out.push("Spec == Init /\\ [][Next]_vars"); function resolve(item) { - const hits = []; - for (const [ri, x] of vars.entries()) { - const inside = x.states.has(item) ? [item] : [...x.states].filter((s) => s.startsWith(`${item}.`)); - if (inside.length) hits.push({ ri, inside }); - } - if (hits.length === 0) throw new Error(`never line names "${item}", which is not a state in the chart`); - if (hits.length > 1) throw new Error(`never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`); - return hits[0]; + const hits = []; + for (const [ri, x] of vars.entries()) { + const inside = x.states.has(item) + ? [item] + : [...x.states].filter((s) => s.startsWith(`${item}.`)); + if (inside.length) hits.push({ ri, inside }); + } + if (hits.length === 0) + throw new Error(`never line names "${item}", which is not a state in the chart`); + if (hits.length > 1) + throw new Error( + `never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`, + ); + return hits[0]; } const invariants = []; for (const n of nevers) { - if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); - taken.add(n.name); - const parts = n.states.map(resolve); - const regionsUsed = new Set(parts.map((p) => p.ri)); - if (regionsUsed.size !== parts.length) throw new Error(`never ${n.name} names two states of one region; one region is in one state at a time`); - const cond = parts.map((p) => (p.inside.length === 1 ? `${vars[p.ri].v} = ${str(p.inside[0])}` : `${vars[p.ri].v} \\in ${set(p.inside)}`)); - out.push(""); - if (n.sentence) out.push(`\\* ${n.sentence}`); - out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); - invariants.push(n.name); + if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); + taken.add(n.name); + const parts = n.states.map(resolve); + const regionsUsed = new Set(parts.map((p) => p.ri)); + if (regionsUsed.size !== parts.length) + throw new Error( + `never ${n.name} names two states of one region; one region is in one state at a time`, + ); + const cond = parts.map((p) => + p.inside.length === 1 + ? `${vars[p.ri].v} = ${str(p.inside[0])}` + : `${vars[p.ri].v} \\in ${set(p.inside)}`, + ); + out.push(""); + if (n.sentence) out.push(`\\* ${n.sentence}`); + out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); + invariants.push(n.name); } out.push("", "====", ""); -// TLC reports only the first rule a state breaks, so the config names no rule and each TLC run appends one. const rules = ["TypeOK", ...invariants]; -const cfg = ["SPECIFICATION Spec", `\\* Rules, one TLC run each: ${rules.join(" ")}`, ""]; +const cfg = ["SPECIFICATION Spec", ...rules.map((r) => `INVARIANT ${r}`), ""]; const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); const map = [ - `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, - `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, - `const LABELS = ${JSON.stringify(labels, null, "\t")};`, - "", - "// The actions the reader takes. facts.settled and --cover need them. Add cause() and detail() as the spec grows.", - "export const readerActions = [];", - 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', - "export const step = (action) => LABELS[action] ?? action;", - "", + `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, + `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, + `const LABELS = ${JSON.stringify(labels, null, "\t")};`, + "", + "// The actions the reader takes. facts.settled needs them. Add cause() and detail() as the spec grows.", + "export const readerActions = [];", + 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', + "export const step = (action) => LABELS[action] ?? action;", + "", ]; fs.mkdirSync(dir, { recursive: true }); @@ -165,5 +252,5 @@ fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); console.error( - `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, ); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs index 3a22f7187..d3de630e7 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs @@ -1,91 +1,104 @@ #!/usr/bin/env node -// Compare two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled transitions. -// -// node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd -// -// Use it to check that a spec still draws the chart the reader agreed to: EXPECTED is the agreed chart, -// ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer view with --highlight 0 --all-labels, -// without --cover or --fixed-dot. A chart with parallel regions compares as its combined machine -// (productOf in lib.mjs), so a guard that blocks a step in only some combinations of regions shows as a -// difference. A combined state is the region states joined by " | " in region order, which is what -// chart-to-tla.mjs's mapping writes. An edge label "a or b" counts as two labels. An unlabeled edge counts -// only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only the labels. -// Exits 1 when the charts differ, and 2 when it cannot read a chart or ACTUAL is not drawn for comparison. import fs from "node:fs"; import process from "node:process"; -import { parseMermaid, productOf } from "./lib.mjs"; +import { LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; -const [expectedFile, actualFile] = process.argv.slice(2); -if (!expectedFile || !actualFile) { - console.error("Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"); - process.exit(2); +const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"; +const HELP = `${USAGE} + +Compares two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled +transitions. statechart.mjs from-chart runs it to check that the spec still draws the chart that +the reader agreed to. + +EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer +view with --highlight 0 --all-labels --flat, without --cover, --fixed-dot, or --against-dot. Each +chart compares as the +machine that its steps reach from its start states. A chart with parallel regions compares as its +combined machine, so a guard that blocks a step in only some combinations of regions shows as a +difference. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition is its guard: +the step happens only while another region is in, or not in, one of those states. The label ends +with the guard's words in [ ], which are not part of the label. A combined state is the region +states joined by " | " in region order, which is what chart-to-tla.mjs's mapping writes. A step that does not change the state is not compared, because +tlc-to-chart.mjs does not draw it. An edge label "a or b" counts as two labels. An unlabeled edge +counts only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only +the labels. + + -h, --help Print this help, and exit 0. + +Prints each difference and then "N difference(s)", or prints "same". Writes no file. + +Exit codes + 0 The charts are the same. + 1 The charts differ. + 2 A bad argument, a chart that it cannot read, or an ACTUAL chart that is not drawn for + comparison.`; +const more = "Run node compare-charts.mjs --help for the flags."; + +const files = []; +for (const a of process.argv.slice(2)) { + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a.startsWith("-") || files.length === 2) { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } else files.push(a); } +if (files.length < 2) { + console.error(`${USAGE}\n${more}`); + process.exit(2); +} +const [expectedFile, actualFile] = files; -const machine = (regions) => { - const rs = Object.values(regions); - return rs.length === 1 ? rs[0] : productOf(regions); -}; -const read = (file) => { - try { - return parseMermaid(fs.readFileSync(file, "utf8")).regions; - } catch (e) { - console.error(`${file}: ${e.message}`); - process.exit(2); - } +const orExit = (file, f) => { + try { + return f(); + } catch (e) { + console.error(`${file}: ${e.message}`); + process.exit(2); + } }; -const expectedRegions = read(expectedFile); -const expected = machine(expectedRegions); -const actual = machine(read(actualFile)); +const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8"))); +const textRefusals = [ + [ + /^\s*%% second model/m, + "it has a second model; draw ACTUAL without --fixed-dot or --against-dot", + ], + [ + new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m"), + "it has boxes; draw ACTUAL with --flat", + ], +]; +for (const [bad, why] of textRefusals) { + if (bad.test(actualText)) { + console.error(`${actualFile}: ${why}`); + process.exit(2); + } +} +const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions); +const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions); +const drawn = Object.values(actualRegions).flatMap((r) => r.transitions); const refusals = [ - [(l) => / or …$/.test(l), "an edge label ends in \" or …\"; draw ACTUAL with --all-labels"], - [(l) => /^\d+(, \d+)*\. /.test(l), "an edge label is a numbered path step; draw ACTUAL with --highlight 0"], - [(l) => /with the fix\)?$|blocked by the fix$/.test(l), "an edge comes from the fixed model; draw ACTUAL without --fixed-dot"], + [(l) => / or …$/.test(l), 'an edge label ends in " or …"; draw ACTUAL with --all-labels'], + [ + (l) => /^\d+(, \d+)*\. /.test(l), + "an edge label is a numbered path step; draw ACTUAL with --highlight 0", + ], ]; for (const [bad, why] of refusals) { - if (actual.transitions.some((t) => bad(t.label))) { - console.error(`${actualFile}: ${why}`); - process.exit(2); - } + if (drawn.some((t) => bad(t.label))) { + console.error(`${actualFile}: ${why}`); + process.exit(2); + } } -const edgeSet = (m) => { - const labeled = new Set(); - const out = new Set(); - for (const t of m.transitions) { - if (!t.label) continue; - labeled.add(`${t.from} --> ${t.to}`); - for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); - } - for (const t of m.transitions) if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); - return out; -}; -const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); - -// A region state that no combination of the agreed chart's own steps reaches can never be drawn. -const unreached = []; -if (Object.keys(expectedRegions).length > 1) { - Object.entries(expectedRegions).forEach(([name, r], ri) => { - const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); - for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); - }); -} - -const rows = [ - ["region states the agreed chart's own steps never reach", unreached.sort()], - ["states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", minus(expected.states, actual.states)], - ["states only in ACTUAL", minus(actual.states, expected.states)], - ["start states only in EXPECTED", minus(expected.initial, actual.initial)], - ["start states only in ACTUAL", minus(actual.initial, expected.initial)], - ["transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", minus(edgeSet(expected), edgeSet(actual))], - ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], -].filter(([, xs]) => xs.length > 0); - +const rows = compareCharts(expectedRegions, actualRegions); let differences = 0; for (const [title, xs] of rows) { - console.log(`${title}:`); - for (const x of xs) console.log(` ${x}`); - differences += xs.length; + console.log(`${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; } console.log(differences === 0 ? "same" : `${differences} difference(s)`); process.exit(differences === 0 ? 0 : 1); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index c1164ccf8..8fbac3be2 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -1,236 +1,301 @@ -// Shared parsing for the statechart scripts. Node built-ins only. +// Shared code for the statechart scripts. Node built-ins only. +import fs from "node:fs"; +import process from "node:process"; + +// ---------- Where TLC is ---------- + +// tla2tools.jar and the java binary, from TLA2TOOLS and JAVA. Throws where to get TLC when TLA2TOOLS is not set. +export function findTlc() { + const { TLA2TOOLS, JAVA } = process.env; + if (!TLA2TOOLS) + throw new Error( + "Cannot find TLC. Download tla2tools.jar from a TLA+ release:\n https://github.com/tlaplus/tlaplus/releases\nThen set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).", + ); + if (!fs.existsSync(TLA2TOOLS)) throw new Error(`TLA2TOOLS is ${TLA2TOOLS}, which does not exist.`); + return { jar: TLA2TOOLS, java: JAVA || "java" }; +} // ---------- TLA+ values as TLC prints them ---------- export function parseValue(text) { - const src = text.trim(); - let i = 0; - const ws = () => { - while (i < src.length && /\s/.test(src[i])) i++; - }; - const peek = (s) => src.startsWith(s, i); - const eat = (s) => { - ws(); - if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); - i += s.length; - }; - const list = (close, item) => { - const out = []; - ws(); - if (peek(close)) { - i += close.length; - return out; - } - for (;;) { - out.push(item()); - ws(); - if (peek(",")) { - i++; - continue; - } - eat(close); - return out; - } - }; - const value = () => { - ws(); - if (peek("<<")) { - i += 2; - return list(">>", value); - } - if (peek("{")) { - i++; - return list("}", value); - } - if (peek("[")) { - i++; - const pairs = list("]", () => { - ws(); - const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); - if (!m) throw new Error(`bad record field at ${i}`); - i += m[0].length; - eat("|->"); - return [m[0], value()]; - }); - return Object.fromEntries(pairs); - } - if (peek("(")) { - i++; - const fn = {}; - for (;;) { - const k = value(); - eat(":>"); - fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); - ws(); - if (peek("@@")) { - i += 2; - continue; - } - eat(")"); - return fn; - } - } - if (peek('"')) { - i++; - let s = ""; - while (i < src.length && src[i] !== '"') { - if (src[i] === "\\") i++; - s += src[i++]; - } - i++; - return s; - } - const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); - if (!m) throw new Error(`bad value at ${i} in ${src}`); - i += m[0].length; - if (m[0] === "TRUE") return true; - if (m[0] === "FALSE") return false; - return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; - }; - try { - const v = value(); - ws(); - return i === src.length ? v : src; - } catch { - return src; - } + const src = text.trim(); + let i = 0; + const ws = () => { + while (i < src.length && /\s/.test(src[i])) i++; + }; + const peek = (s) => src.startsWith(s, i); + const eat = (s) => { + ws(); + if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); + i += s.length; + }; + const list = (close, item) => { + const out = []; + ws(); + if (peek(close)) { + i += close.length; + return out; + } + for (;;) { + out.push(item()); + ws(); + if (peek(",")) { + i++; + continue; + } + eat(close); + return out; + } + }; + const value = () => { + ws(); + if (peek("<<")) { + i += 2; + return list(">>", value); + } + if (peek("{")) { + i++; + return list("}", value); + } + if (peek("[")) { + i++; + const pairs = list("]", () => { + ws(); + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad record field at ${i}`); + i += m[0].length; + eat("|->"); + return [m[0], value()]; + }); + return Object.fromEntries(pairs); + } + if (peek("(")) { + i++; + const fn = {}; + for (;;) { + const k = value(); + eat(":>"); + fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); + ws(); + if (peek("@@")) { + i += 2; + continue; + } + eat(")"); + return fn; + } + } + if (peek('"')) { + i++; + let s = ""; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") i++; + s += src[i++]; + } + i++; + return s; + } + const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad value at ${i} in ${src}`); + i += m[0].length; + if (m[0] === "TRUE") return true; + if (m[0] === "FALSE") return false; + return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; + }; + try { + const v = value(); + ws(); + return i === src.length ? v : src; + } catch { + return src; + } } // One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. export function parseState(text) { - const lines = text.split("\n").filter((l) => l.trim() !== ""); - const conjuncts = []; - for (const line of lines) { - if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) conjuncts.push(line.replace(/^\s*\/\\ /, "")); - else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; - } - const vars = {}; - for (const c of conjuncts) { - const eq = c.indexOf(" = "); - if (eq < 0) continue; - vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); - } - return vars; + const lines = text.split("\n").filter((l) => l.trim() !== ""); + const conjuncts = []; + for (const line of lines) { + if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) + conjuncts.push(line.replace(/^\s*\/\\ /, "")); + else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; + } + const vars = {}; + for (const c of conjuncts) { + const eq = c.indexOf(" = "); + if (eq < 0) continue; + vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); + } + return vars; } -export function stateKey(vars) { - return JSON.stringify(Object.keys(vars).sort().map((k) => [k, vars[k]])); +export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const canon = (v) => + Array.isArray(v) + ? v.map(canon) + : isRecord(v) + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, canon(v[k])]), + ) + : v; +// Record fields sort at every depth, so a state keeps its key when a field is added back. +export const stateKey = (vars) => JSON.stringify(canon(vars)); + +// The rule sentence of each definition: the \* comment lines right above NAME ==. +export function ruleSentences(tla) { + const out = new Map(); + const lines = tla.split("\n"); + lines.forEach((line, i) => { + const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1]; + if (!name) return; + const said = []; + for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--) + said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim()); + const sentence = said.filter(Boolean).join(" "); + if (sentence) out.set(name, sentence); + }); + return out; } // ---------- TLC -dump dot,actionlabels ---------- function unescapeDot(s) { - let out = ""; - for (let i = 0; i < s.length; i++) { - if (s[i] === "\\" && i + 1 < s.length) { - const n = s[++i]; - out += n === "n" ? "\n" : n; - } else out += s[i]; - } - return out; + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const n = s[++i]; + out += n === "n" ? "\n" : n; + } else out += s[i]; + } + return out; } // Returns { nodes, edges, loops }. loops are steps that change no variable; they count as enabled // actions but are not transitions. export function parseDot(text) { - const nodes = new Map(); - const edges = []; - const loops = []; - const seen = new Set(); - const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; - const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); - const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); - for (const line of text.split("\n")) { - let m = edgeRe.exec(line); - if (m) { - const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; - const id = `${e.from} ${e.to} ${e.action}`; - if (seen.has(id)) continue; - seen.add(id); - (e.from === e.to ? loops : edges).push(e); - continue; - } - m = nodeRe.exec(line); - if (m) nodes.set(m[1], { id: m[1], vars: parseState(unescapeDot(m[2])), init: /style\s*=\s*filled/.test(m[3]) }); - } - if (nodes.size === 0) throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); - const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); - if (missing) throw new Error(`transition ${missing.from} -> ${missing.to} names a state the file does not define`); - if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) throw new Error("the graph has no action names; dump it with TLC -dump dot,actionlabels "); - return { nodes, edges, loops }; + const nodes = new Map(); + const edges = []; + const loops = []; + const seen = new Set(); + const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; + const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); + const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); + for (const line of text.split("\n")) { + let m = edgeRe.exec(line); + if (m) { + const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; + const id = `${e.from} ${e.to} ${e.action}`; + if (seen.has(id)) continue; + seen.add(id); + (e.from === e.to ? loops : edges).push(e); + continue; + } + m = nodeRe.exec(line); + if (m) + nodes.set(m[1], { + id: m[1], + vars: parseState(unescapeDot(m[2])), + init: /style\s*=\s*filled/.test(m[3]), + }); + } + if (nodes.size === 0) + throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); + const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + if (missing) + throw new Error( + `transition ${missing.from} -> ${missing.to} names a state the file does not define`, + ); + if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) + throw new Error( + "the graph has no action names; dump it with TLC -dump dot,actionlabels ", + ); + return { nodes, edges, loops }; } // ---------- TLC error traces (one per violation; many with -continue) ---------- // A trace that follows an error other than a broken invariant has invariant null. export function parseTraces(text) { - const traces = []; - let invariant = null; - let current = null; - const lines = text.split("\n"); - const stateAt = (i) => { - const body = []; - while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); - return [parseState(body.join("\n")), i]; - }; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); - if (inv) { - invariant = inv[1]; - if (inv[2]) { - const [vars, end] = stateAt(i); - traces.push({ invariant, steps: [{ action: null, vars }] }); - i = end; - } - continue; - } - if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) invariant = null; - const head = /^State (\d+): <([^\s>(]+)/.exec(line); - if (!head) continue; - const [vars, end] = stateAt(i); - i = end; - if (head[1] === "1") { - current = { invariant, steps: [] }; - traces.push(current); - } - current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); - } - return traces; + const traces = []; + let invariant = null; + let current = null; + const lines = text.split("\n"); + const stateAt = (i) => { + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + return [parseState(body.join("\n")), i]; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); + if (inv) { + invariant = inv[1]; + if (inv[2]) { + const [vars, end] = stateAt(i); + traces.push({ invariant, steps: [{ action: null, vars }] }); + i = end; + } + continue; + } + if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) + invariant = null; + const head = /^State (\d+): <([^\s>(]+)/.exec(line); + if (!head) continue; + const [vars, end] = stateAt(i); + i = end; + if (head[1] === "1") { + current = { invariant, steps: [] }; + traces.push(current); + } + current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); + } + return traces; } // Throws unless TLC finished and every error it printed is a broken invariant. With -continue, TLC // prints "Model checking completed" even after broken invariants, and not when it stopped early. export function checkTlcOutput(text, file) { - const other = text - .split("\n") - .find((l) => l.startsWith("Error: ") && !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l)); - if (other) throw new Error(`${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`); - if (!/^Model checking completed\./m.test(text)) throw new Error(`${file}: TLC did not finish, so its counterexamples are not all there; read the file`); + const other = text + .split("\n") + .find( + (l) => + l.startsWith("Error: ") && + !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l), + ); + if (other) + throw new Error( + `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`, + ); + if (!/^Model checking completed\./m.test(text)) + throw new Error( + `${file}: TLC did not finish, so its counterexamples are not all there; read the file`, + ); } // ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- export function wrap(text, width = 26) { - const words = String(text).split(/\s+/); - const rows = []; - let row = ""; - for (const w of words) { - if (row && (row + " " + w).length > width) { - rows.push(row); - row = w; - } else row = row ? `${row} ${w}` : w; - } - if (row) rows.push(row); - return rows.join("
"); + const words = String(text).split(/\s+/); + const rows = []; + let row = ""; + for (const w of words) { + if (row && (row + " " + w).length > width) { + rows.push(row); + row = w; + } else row = row ? `${row} ${w}` : w; + } + if (row) rows.push(row); + return rows.join("
"); } const unwrap = (s) => - s - .replace(//g, " ") - .replace(/#(quot|34);/g, '"') - .replace(/\s+/g, " ") - .trim(); + s + .replace(//g, " ") + .replace(/#(quot|34);/g, '"') + .replace(/\s+/g, " ") + .trim(); // Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. export const quote = (s) => String(s).replaceAll('"', "#quot;"); @@ -238,240 +303,449 @@ export const quote = (s) => String(s).replaceAll('"', "#quot;"); // The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; -// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label}] } }, nevers }. +// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label,guard?}] } }, nevers }. // Composite states flatten to "Parent.Child". A transition on a composite leaves from each state // inside it that has no transition with the same label, and enters at the composite's [*] start. // One top-level composite split by "--" makes one region per part. Regions nest only at the top. +// Words in [ ] in a label say when the step can happen. They are not part of the label, and nothing +// checks them. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition guards it +// on the states of other regions; its label must have the guard's words in [ ]. +// guard is { not, states: { region: [state] } }. export function parseMermaid(text) { - const names = new Map(); - const nevers = []; - const newPart = () => ({ children: [], transitions: [] }); - const root = { name: "", parts: [newPart()] }; - const stack = [root]; - const part = () => stack[stack.length - 1].parts.at(-1); - const display = (id) => names.get(id) ?? id; - // As in Mermaid, a state that only transitions name belongs to the part where it first appears. - const implicit = new Map(); - const declare = (id) => { - const prev = implicit.get(id); - if (prev) prev.children.splice(prev.children.findIndex((c) => c.name === id), 1); - implicit.set(id, null); - }; - const mention = (id) => { - if (id === "[*]" || implicit.has(id)) return; - part().children.push({ name: id, leaf: true }); - implicit.set(id, part()); - }; - for (const raw of text.split("\n")) { - const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); - const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); - if (never) { - nevers.push({ name: never[1], states: never[2].split("&").map((s) => s.trim()), sentence: never[3] ?? "" }); - continue; - } - if (!line || line.startsWith("%%") || /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line)) continue; - let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); - if (!m) { - const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); - if (plain) m = [line, null, plain[1], plain[2]]; - } - if (m) { - if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); - declare(m[2]); - if (m[3]) { - const node = { name: m[2], parts: [newPart()] }; - part().children.push(node); - stack.push(node); - } else part().children.push({ name: m[2], leaf: true }); - continue; - } - if (line === "}") { - if (stack.length === 1) throw new Error("unmatched } in the chart"); - stack.pop(); - continue; - } - if (line === "--") { - stack[stack.length - 1].parts.push(newPart()); - continue; - } - m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); - if (m) { - if (m[2] === "[*]" && m[1] !== "[*]") throw new Error(`cannot read final state in "${line}"; draw the step to the state the reader sees next`); - mention(m[1]); - mention(m[2]); - part().transitions.push({ from: m[1], to: m[2], label: m[3] ? unwrap(m[3]) : "" }); - continue; - } - m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); - if (m) { - // Mermaid shows every description line; the first one names the state. - if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); - if (implicit.get(m[1]) === null) continue; - declare(m[1]); - part().children.push({ name: m[1], leaf: true }); - continue; - } - throw new Error(`cannot read chart line: ${line}`); - } - if (stack.length !== 1) throw new Error("the chart has an unclosed {"); - if (root.parts.length > 1) throw new Error("the chart has -- outside a composite; to make parallel regions, split one top-level composite with --"); + const names = new Map(); + const nevers = []; + let last = null; + const newPart = () => ({ children: [], transitions: [] }); + const root = { name: "", parts: [newPart()] }; + const stack = [root]; + const part = () => stack[stack.length - 1].parts.at(-1); + const display = (id) => names.get(id) ?? id; + // As in Mermaid, a state that only transitions name belongs to the part where it first appears. + const implicit = new Map(); + const declare = (id) => { + const prev = implicit.get(id); + if (prev) + prev.children.splice( + prev.children.findIndex((c) => c.name === id), + 1, + ); + implicit.set(id, null); + }; + const mention = (id) => { + if (id === "[*]" || implicit.has(id)) return; + part().children.push({ name: id, leaf: true }); + implicit.set(id, part()); + }; + for (const raw of text.split("\n")) { + const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); + const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); + if (when) { + if (!last) throw new Error(`a %% when line must come right after a transition: ${line}`); + if (!last.words) + throw new Error( + `the guarded step "${last.label}" must end its label with the guard's words in [ ]`, + ); + last.when = { not: Boolean(when[1]), ids: when[2].split(",").map((s) => s.trim()) }; + last = null; + continue; + } + last = null; + const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); + if (never) { + nevers.push({ + name: never[1], + states: never[2].split("&").map((s) => s.trim()), + sentence: never[3] ?? "", + }); + continue; + } + if ( + !line || + line.startsWith("%%") || + /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line) + ) + continue; + let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (!m) { + const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (plain) m = [line, null, plain[1], plain[2]]; + } + if (m) { + if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); + declare(m[2]); + if (m[3]) { + const node = { name: m[2], parts: [newPart()] }; + part().children.push(node); + stack.push(node); + } else part().children.push({ name: m[2], leaf: true }); + continue; + } + if (line === "}") { + if (stack.length === 1) throw new Error("unmatched } in the chart"); + stack.pop(); + continue; + } + if (line === "--") { + stack[stack.length - 1].parts.push(newPart()); + continue; + } + m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); + if (m) { + if (m[2] === "[*]" && m[1] !== "[*]") + throw new Error( + `cannot read final state in "${line}"; draw the step to the state the reader sees next`, + ); + mention(m[1]); + mention(m[2]); + const said = m[3] ? unwrap(m[3]) : ""; + const label = said.replace(/\s*\[[^\]]*\]/g, ""); + last = { from: m[1], to: m[2], label, words: label !== said }; + part().transitions.push(last); + continue; + } + m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); + if (m) { + // Mermaid shows every description line; the first one names the state. + if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); + if (implicit.get(m[1]) === null) continue; + declare(m[1]); + part().children.push({ name: m[1], leaf: true }); + continue; + } + throw new Error(`cannot read chart line: ${line}`); + } + if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + if (root.parts.length > 1) + throw new Error( + "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --", + ); - // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. - // A composite's full name prefixes its children ("Parent.Child"). An id used only in a - // transition is a plain state in the part where it first appears. - function region(p) { - const out = { states: new Set(), initial: new Set(), transitions: [] }; - const info = new Map(); - const declared = new Set(); - (function collect(q) { - for (const c of q.children) { - if (c.name === LEGEND_ID) continue; - declared.add(c.name); - if (!c.leaf) for (const sub of c.parts) collect(sub); - } - })(p); - const transitions = []; - const lookup = (id) => { - const found = info.get(id); - if (!found) throw new Error(`transition names ${id}, which is not in this region`); - return found; - }; - function states(q, prefix, depth) { - const local = q.children.filter((c) => c.name !== LEGEND_ID); - for (const t of q.transitions) { - for (const end of [t.from, t.to]) { - if (end === "[*]" || declared.has(end)) continue; - declared.add(end); - local.push({ name: end, leaf: true }); - } - } - const all = []; - for (const c of local) { - if (info.has(c.name)) { - if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; - throw new Error(`state id ${c.name} is declared in two places`); - } - const full = `${prefix}${display(c.name)}`; - if (c.leaf) { - if (out.states.has(full)) throw new Error(`two states have the caption "${full}"; give each its own caption`); - out.states.add(full); - info.set(c.name, { all: [full], initial: [full], depth }); - all.push(full); - continue; - } - if (c.parts.length > 1) throw new Error(`${display(c.name)} splits into regions below the top level; make each region a top-level region`); - const inner = states(c.parts[0], `${full}.`, depth + 1); - const initial = c.parts[0].transitions.filter((t) => t.from === "[*]").flatMap((t) => lookup(t.to).initial); - if (initial.length === 0) throw new Error(`${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`); - info.set(c.name, { all: inner, initial, depth }); - all.push(...inner); - } - for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); - return all; - } - states(p, "", 0); - for (const t of p.transitions) if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); - // The deepest source wins: a composite's transition leaves only from states inside it - // that have no deeper transition with the same label. - transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); - const handled = new Set(); - let pending = []; - let depth = null; - for (const t of transitions) { - const from = lookup(t.from); - if (from.depth !== depth) { - for (const h of pending) handled.add(h); - pending = []; - depth = from.depth; - } - for (const f of from.all) { - const key = `${f}\u0000${t.label}`; - if (t.label && handled.has(key)) continue; - for (const to of lookup(t.to).initial) out.transitions.push({ from: f, to, label: t.label }); - if (t.label) pending.push(key); - } - } - return out; - } + // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. + // A composite's full name prefixes its children ("Parent.Child"). An id used only in a + // transition is a plain state in the part where it first appears. + function region(p) { + const out = { states: new Set(), initial: new Set(), transitions: [] }; + const info = new Map(); + const declared = new Set(); + (function collect(q) { + for (const c of q.children) { + if (c.name === LEGEND_ID) continue; + declared.add(c.name); + if (!c.leaf) for (const sub of c.parts) collect(sub); + } + })(p); + const transitions = []; + const lookup = (id) => { + const found = info.get(id); + if (!found) throw new Error(`transition names ${id}, which is not in this region`); + return found; + }; + function states(q, prefix, depth) { + const local = q.children.filter((c) => c.name !== LEGEND_ID); + for (const t of q.transitions) { + for (const end of [t.from, t.to]) { + if (end === "[*]" || declared.has(end)) continue; + declared.add(end); + local.push({ name: end, leaf: true }); + } + } + const all = []; + for (const c of local) { + if (info.has(c.name)) { + if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; + throw new Error(`state id ${c.name} is declared in two places`); + } + const full = `${prefix}${display(c.name)}`; + if (c.leaf) { + if (out.states.has(full)) + throw new Error(`two states have the caption "${full}"; give each its own caption`); + out.states.add(full); + info.set(c.name, { all: [full], initial: [full], depth }); + all.push(full); + continue; + } + if (c.parts.length > 1) + throw new Error( + `${display(c.name)} splits into regions below the top level; make each region a top-level region`, + ); + const inner = states(c.parts[0], `${full}.`, depth + 1); + const initial = c.parts[0].transitions + .filter((t) => t.from === "[*]") + .flatMap((t) => lookup(t.to).initial); + if (initial.length === 0) + throw new Error( + `${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`, + ); + info.set(c.name, { all: inner, initial, depth }); + all.push(...inner); + } + for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); + return all; + } + states(p, "", 0); + for (const t of p.transitions) + if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); + // The deepest source wins: a composite's transition leaves only from states inside it + // that have no deeper transition with the same label. + transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); + const handled = new Set(); + let pending = []; + let depth = null; + for (const t of transitions) { + const from = lookup(t.from); + if (from.depth !== depth) { + for (const h of pending) handled.add(h); + pending = []; + depth = from.depth; + } + for (const f of from.all) { + const key = `${f}\u0000${t.label}`; + if (t.label && handled.has(key)) continue; + for (const to of lookup(t.to).initial) + out.transitions.push({ + from: f, + to, + label: t.label, + ...(t.when ? { when: t.when } : {}), + }); + if (t.label) pending.push(key); + } + } + for (const [id, { all }] of info) owners.set(id, { name: regionName, all }); + return out; + } + const owners = new Map(); + let regionName = ""; - const regions = {}; - const top = root.parts[0]; - const machine = top.children.filter((c) => c.name !== LEGEND_ID); - const parallel = machine.length === 1 && !machine[0].leaf && machine[0].parts.length > 1 && top.transitions.every((t) => t.from === "[*]"); - if (parallel) { - for (const p of machine[0].parts) { - const [only] = p.children; - const holder = p.children.length === 1 && !only.leaf && p.transitions.every((t) => t.from === "[*]" && t.to === only.name) ? only : null; - if (holder && holder.parts.length > 1) throw new Error(`${display(holder.name)} splits into regions below the top level`); - regions[holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`] = region(holder ? holder.parts[0] : p); - } - } else regions.Main = region(top); - return { regions, nevers }; + const regions = {}; + const top = root.parts[0]; + const machine = top.children.filter((c) => c.name !== LEGEND_ID); + const parallel = + machine.length === 1 && + !machine[0].leaf && + machine[0].parts.length > 1 && + top.transitions.every((t) => t.from === "[*]"); + if (parallel) { + for (const p of machine[0].parts) { + const [only] = p.children; + const holder = + p.children.length === 1 && + !only.leaf && + p.transitions.every((t) => t.from === "[*]" && t.to === only.name) + ? only + : null; + if (holder && holder.parts.length > 1) + throw new Error(`${display(holder.name)} splits into regions below the top level`); + regionName = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; + regions[regionName] = region(holder ? holder.parts[0] : p); + } + } else { + regionName = "Main"; + regions.Main = region(top); + } + for (const [name, r] of Object.entries(regions)) + for (const t of r.transitions) { + if (!t.when) continue; + if (!parallel) + throw new Error("a %% when line guards a step on other regions, and the chart has none"); + const states = {}; + for (const id of t.when.ids) { + const at = owners.get(id); + if (!at) + throw new Error(`a %% when line names ${id}, which is not a state id in the chart`); + if (at.name === name) + throw new Error( + `a %% when line names ${display(id)}, which is in the step's own region; a guard reads other regions`, + ); + states[at.name] = [...new Set([...(states[at.name] ?? []), ...at.all])].sort(); + } + t.guard = { not: t.when.not, states }; + delete t.when; + } + return { regions, nevers }; +} + +// Whether a guard lets a step happen while the regions are in the states that `at` maps them to. +export function guardHolds(guard, at) { + if (!guard) return true; + const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r])); + return guard.not ? !hits.some(Boolean) : hits.every(Boolean); } // The combined machine of a chart's regions, as chart-to-tla.mjs generates it. A labeled step can // happen when at least one region can take it; every region that can take it moves, the others stay. +// A region can take a transition only while its guard holds in the state before the step. // An unlabeled transition moves its own region only. A combined state is the region states joined // by " | " in region order. Only states reachable from the start are kept, and steps that change // nothing are dropped, as TLC's state graph drops them. export function productOf(regions) { - const rs = Object.values(regions); - const join = (parts) => parts.join(" | "); - const byLabel = new Map(); - const unlabeled = []; - rs.forEach((r, ri) => { - for (const t of r.transitions) { - if (!t.label) { - unlabeled.push({ ri, from: t.from, to: t.to }); - continue; - } - if (!byLabel.has(t.label)) byLabel.set(t.label, rs.map(() => new Map())); - const moves = byLabel.get(t.label)[ri]; - if (!moves.has(t.from)) moves.set(t.from, new Set()); - moves.get(t.from).add(t.to); - } - }); - let starts = [[]]; - for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); - const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; - const seen = new Set(starts.map(join)); - const queue = [...starts]; - const step = (from, to, label) => { - const [a, b] = [join(from), join(to)]; - if (a === b) return; - out.transitions.push({ from: a, to: b, label }); - if (!seen.has(b)) { - seen.add(b); - queue.push(to); - } - }; - while (queue.length) { - const cur = queue.shift(); - out.states.add(join(cur)); - for (const u of unlabeled) { - if (cur[u.ri] !== u.from) continue; - const next = [...cur]; - next[u.ri] = u.to; - step(cur, next, ""); - } - for (const [label, perRegion] of byLabel) { - if (!perRegion.some((moves, ri) => moves.has(cur[ri]))) continue; - let nexts = [[]]; - perRegion.forEach((moves, ri) => { - const tos = moves.has(cur[ri]) ? [...moves.get(cur[ri])] : [cur[ri]]; - nexts = nexts.flatMap((p) => tos.map((s) => [...p, s])); - }); - for (const next of nexts) step(cur, next, label); - } - } - return out; + const names = Object.keys(regions); + const rs = Object.values(regions); + const join = (parts) => parts.join(" | "); + const byLabel = new Map(); + const unlabeled = []; + rs.forEach((r, ri) => { + for (const t of r.transitions) { + if (!t.label) { + unlabeled.push({ ri, from: t.from, to: t.to, guard: t.guard }); + continue; + } + if (!byLabel.has(t.label)) + byLabel.set( + t.label, + rs.map(() => new Map()), + ); + const moves = byLabel.get(t.label)[ri]; + if (!moves.has(t.from)) moves.set(t.from, []); + moves.get(t.from).push({ to: t.to, guard: t.guard }); + } + }); + let starts = [[]]; + for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); + const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; + const seen = new Set(starts.map(join)); + const queue = [...starts]; + const step = (from, to, label) => { + const [a, b] = [join(from), join(to)]; + if (a === b) return; + out.transitions.push({ from: a, to: b, label }); + if (!seen.has(b)) { + seen.add(b); + queue.push(to); + } + }; + while (queue.length) { + const cur = queue.shift(); + out.states.add(join(cur)); + const at = Object.fromEntries(names.map((n, ri) => [n, cur[ri]])); + for (const u of unlabeled) { + if (cur[u.ri] !== u.from || !guardHolds(u.guard, at)) continue; + const next = [...cur]; + next[u.ri] = u.to; + step(cur, next, ""); + } + for (const [label, perRegion] of byLabel) { + const open = perRegion.map((moves, ri) => + (moves.get(cur[ri]) ?? []).filter((m) => guardHolds(m.guard, at)).map((m) => m.to), + ); + if (open.every((tos) => tos.length === 0)) continue; + let nexts = [[]]; + open.forEach((tos, ri) => { + const options = tos.length ? [...new Set(tos)] : [cur[ri]]; + nexts = nexts.flatMap((p) => options.map((s) => [...p, s])); + }); + for (const next of nexts) step(cur, next, label); + } + } + return out; +} + +// Compares two charts as the machines their steps reach. Returns [title, differences] rows, only +// the rows that have differences. An edge label "a or b" counts as two labels. An unlabeled edge +// counts only when no labeled edge joins the same two states. +export function compareCharts(expectedRegions, actualRegions) { + const expected = productOf(expectedRegions); + const actual = productOf(actualRegions); + const edgeSet = (m) => { + const labeled = new Set(); + const out = new Set(); + for (const t of m.transitions) { + if (!t.label) continue; + labeled.add(`${t.from} --> ${t.to}`); + for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); + } + for (const t of m.transitions) + if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); + return out; + }; + const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + // A region state that no combination of the agreed chart's own steps reaches can never be drawn. + const unreached = []; + const regionEntries = Object.entries(expectedRegions); + if (regionEntries.length === 1) { + for (const s of regionEntries[0][1].states) if (!expected.states.has(s)) unreached.push(s); + } else { + regionEntries.forEach(([name, r], ri) => { + const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); + for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); + }); + } + return [ + ["states the agreed chart's own steps never reach", unreached.sort()], + [ + "states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", + minus(expected.states, actual.states), + ], + ["states only in ACTUAL", minus(actual.states, expected.states)], + ["start states only in EXPECTED", minus(expected.initial, actual.initial)], + ["start states only in ACTUAL", minus(actual.initial, expected.initial)], + [ + "transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", + minus(edgeSet(expected), edgeSet(actual)), + ], + ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], + ].filter(([, xs]) => xs.length > 0); } export const STATUS_CLASSES = [ - "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", - "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", - "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", - "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", - "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", - "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", + "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", + "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", + "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", + "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", + "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", ]; + +// Fills for the mapping's groups that it gives no color: light, and not red, green, or gray, so a +// status border stays clear on each. +export const GROUP_PALETTE = [ + "#d6eaf8", + "#fdebd0", + "#e8daef", + "#fcf3cf", + "#d1f2eb", + "#ede0d4", + "#f6e0ef", +]; +export const PLAIN_FILL = "#f4f6f7"; + +const rgb = (hex) => { + const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); +}; +// WCAG relative luminance: 0 for black, 1 for white. +export function luminance(hex) { + const [r, g, b] = rgb(hex).map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +// Why a group color cannot fill a state, or null when it can. +export function groupColorProblem(color) { + if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) + return 'is not "#rgb" or "#rrggbb"'; + if (luminance(color) < 0.5) return "is too dark for the status borders to show on it"; + return null; +} + +// A status border sits on top of the group fill. The path border shows only on states that work. +const BORDERS = { + Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"], + Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"], + Path: ["stroke:#1f3a93,stroke-width:4px", "#1b2631"], +}; +export function groupClassDef(base, fill, border) { + const darker = `#${rgb(fill) + .map((c) => + Math.round(c / 2) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; + const [stroke, color] = BORDERS[border] ?? [ + `stroke:${fill === PLAIN_FILL ? "#566573" : darker}`, + "#1b2631", + ]; + return `classDef ${base}${border} fill:${fill},${stroke},color:${color}`; +} diff --git a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs new file mode 100644 index 000000000..b69911ae0 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs @@ -0,0 +1,541 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { checkTlcOutput, findTlc, parseDot } from "./lib.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +const USAGE = { + "from-spec": + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", + "from-chart": + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", +}; +const usage = (lines) => `Usage: ${lines.join("\n ")}`; + +const FROM_SPEC = `from-spec SPEC.tla + Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state + breaks, and the separate runs find the rest. SPEC.cfg sits next to the spec and has SPECIFICATION + and one INVARIANT line per rule. One more run dumps the state graph, and then the command draws + the charts. + + --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and + module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks + it the same way, with FIXED.cfg, or with SPEC.cfg when there is no FIXED.cfg. + The charts then mark each bad state fixed or still wrong. + --against AGREED.tla + The spec that SPEC.tla must follow, when SPEC.tla models the code. It needs + its own file and module name. The command checks it with AGREED.cfg, or with + SPEC.cfg when there is no AGREED.cfg. SPEC.tla may add variables, record + fields, and actions that AGREED.tla does not have. The comparison leaves them + out. Do not use it with --fixed. + --label TEXT The name of the --fixed or --against model in the charts and the stories. + Default: "the fix", or "the spec". + --map FILE The labels for each model state and step. Default: SPEC.map.mjs next to the + spec. When the file does not exist, the command writes one to fill in and + exits 2. Its comments say what each function gets.`; + +const FROM_CHART = `from-chart CHART.mmd + Writes NAME.tla, NAME.cfg, and NAME.map.mjs next to the chart. When NAME.tla already exists, it + writes none of them. Then it checks the spec as from-spec does, and compares the chart that the + spec draws with CHART.mmd. + + --module NAME The TLA+ module name. Default: the chart's file name in CamelCase, such as + NotesApp for notes-app.mmd. + + The chart is a Mermaid stateDiagram-v2. Each transition label is one action. For parallel + regions, split one top-level composite with --, and put each part in a named composite, such as + state Connection { ... }. Each region is one variable. A step moves every region whose current + state has a transition with the step's label, and the other regions stay where they are. Each + rule is one comment line: + %% never NAME: A & B -- the rule in one sentence + Each name after the colon is a state caption or a composite, and each comes from a different + region. A composite stands for every state in it. The command refuses final, choice, fork, and + history states, and regions below the top level.`; + +const COMMON = `Both commands + --expect-broken RULE + A rule that is known to break. Repeat it. The command then exits 0 when + exactly these rules break, and the --fixed or --against model breaks no other + rule. + --title TEXT The chart title. Each bug chart adds ", bug N". + --out DIR The output folder. Default: NAME-statechart next to the input. + --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. + -h, --help Print this help, and exit 0. + +Output + The command prints whether each rule holds, the chart's counts, and one story per bug. + from-chart also prints the comparison. The command writes these files in the output folder: + chart.mmd The overview. + bug-N.mmd Only the steps of bug N, numbered. Bugs are numbered by fewest reader steps, + then fewest model steps, then rule name, then cause. Bugs that only the + --fixed or --against model has come last. + bug-N-developer.mmd, bug-N-layered.mmd + The path of bug N through the developer's states, when the mapping exports + detail(). + stories.md One entry per bug: the rule, why it breaks, the shortest path in reader + steps, and what the --fixed or --against model does instead. + summary.json The counts behind the chart. + tlc/ The config and the TLC output of each run. When the overview has regions, + from-spec also writes tlc/regions-all.mmd, the regions with every arrow, and + tlc/regions-proof.mmd, every state and step of TLC's graph in one region. + compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the + proof. + *.png Each chart as a PNG, with --png. + +Reading a chart + from-spec draws the overview as two parallel regions when every state reads "context: progress": + the contexts on one side, the progress on the other, both at the same time. It does so only when + that has fewer states, and when the regions combine back into exactly every state and step of + TLC's graph. Otherwise it puts the states of one context in a box named context, and prints why. + Regions draw only the arrows of the shortest way to each state, of each bug path, and of the + nearest step with each label. The script checks that these arrows make no step that TLC's graph + does not have. + An arrow from a box's border leaves from every state in the box. + In regions, a step leaves every region that has it. "[when A]" or "[unless A]" says which states + of the other region the step needs. A loop says the region may also stay. + Other words in [ ] come from the mapping's guard(), and "after N s" from its timer(). TLC does + not check them, and has no clock, so a reviewer checks them against the code. + Gray states work. Red states break a rule. With --fixed, red states stay wrong with the fix, and + green states are fixed by it or exist only with it. With --against, red states break a rule in + either model, and green states exist only in the spec. + When the mapping exports group(), the fill is the state's group, which the legend names, and the + status is a thick red or green border on top. tlc-to-chart.mjs --help tells more. + A state is bad when it breaks a rule, or when every path from the start to it passes a state + that breaks one. + A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an + edge. + An edge labeled "with the fix" or "in the spec" leaves the state before the step that the other + model changes, and ends where that model goes instead. "blocked by the fix" or "not in the spec" + loops on the state where that model stops the step. "(with the fix)" or "(in the spec)" marks an + edge that only the other model has. + An edge shows its two most common labels. "or …" means that there are more. + +Regenerate + With --expect-broken, one script rebuilds every chart and stops on any change: + set -e + node statechart.mjs from-spec Code.tla --against Agreed.tla --expect-broken NoLostUpload --png + +TLC + TLA2TOOLS is the path to tla2tools.jar, and JAVA is the java binary (default: java, Java 11 or + later). Download tla2tools.jar from https://github.com/tlaplus/tlaplus/releases. When TLA2TOOLS + is not set, the command says where to get TLC and exits 2. + +Exit codes + 0 Every rule holds, or exactly the --expect-broken rules break. from-chart also draws CHART.mmd + back unchanged. + 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not + CHART.mmd. The output says which. + 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it + cannot read, no TLC, or a new mapping to fill in.`; + +const HELP = { + "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} + +Draws a Mermaid statechart of what an app's user sees, from TLC's state graph of a TLA+ spec. The +help calls that user the reader. + +${FROM_SPEC} + +${FROM_CHART} + +${COMMON}`, + "from-spec": `${usage([USAGE["from-spec"]])}\n\n${FROM_SPEC}\n\n${COMMON}`, + "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`, +}; + +function fail(message) { + console.error(message); + process.exit(2); +} + +const [command, ...rest] = process.argv.slice(2); +const more = (cmd) => + `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; +if (command === "-h" || command === "--help") { + console.log(HELP[""]); + process.exit(0); +} +const FLAGS = { + "from-spec": ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + "from-chart": ["--module", "--expect-broken", "--title", "--out"], +}; +if (!FLAGS[command]) fail(`${usage(Object.values(USAGE))}\n${more()}`); +const args = { "expect-broken": [] }; +let input; +for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "-h" || a === "--help") { + console.log(HELP[command]); + process.exit(0); + } else if (a === "--png") args.png = true; + else if (FLAGS[command].includes(a)) { + if (i + 1 === rest.length) fail(`${a} needs a value. ${more(command)}`); + const name = a.slice(2); + if (Array.isArray(args[name])) args[name].push(rest[++i]); + else args[name] = rest[++i]; + } else if (!a.startsWith("-") && input === undefined) input = a; + else fail(`unknown argument ${a}. ${more(command)}`); +} +if (input === undefined) fail(`${usage([USAGE[command]])}\n${more(command)}`); +if (args.fixed && args.against) fail(`use --fixed or --against, not both. ${more(command)}`); +if (args.label !== undefined && !args.fixed && !args.against) + fail(`--label names the --fixed or --against model; it needs one of them. ${more(command)}`); +const expected = new Set(args["expect-broken"]); + +const rel = (file) => path.relative(process.cwd(), file) || "."; +let tools; +function needTlc() { + try { + tools = findTlc(); + } catch (e) { + fail(e.message); + } +} + +// The child scripts throw on input they cannot use. Print the message and the file it is about, not the stack. +function script(name, scriptArgs, about) { + const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { + encoding: "utf8", + }); + if (r.status === 0 || (name === "compare-charts.mjs" && r.status === 1)) return r; + const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; + fail(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); +} + +const KEYWORD = + /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/; + +// The config without its INVARIANT sections, and the rules they name. +function splitRules(cfg) { + const words = cfg + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " ") + .split(/\s+/) + .filter(Boolean); + const kept = []; + const rules = []; + let section = ""; + for (const w of words) { + if (KEYWORD.test(w)) section = w; + if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); + else if (w !== section && !rules.includes(w)) rules.push(w); + } + return { base: `${kept.join(" ")}\n`, rules }; +} + +function tlc(spec, cfg, base, dump) { + fs.writeFileSync(`${base}.cfg`, cfg); + const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); + // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. + const r = spawnSync( + tools.java, + [ + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + metadir, + "-deadlock", + "-continue", + ...(dump ? ["-dump", "dot,actionlabels", path.resolve(dump)] : []), + "-config", + path.resolve(`${base}.cfg`), + path.basename(spec), + ], + { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 }, + ); + fs.rmSync(metadir, { recursive: true, force: true }); + if (r.error) fail(`cannot run TLC: ${r.error.message}`); + fs.writeFileSync(`${base}.out`, r.stdout); + try { + checkTlcOutput(r.stdout, rel(`${base}.out`)); + } catch (e) { + fail(e.message); + } + return r.stdout; +} + +// main is true for the input spec: --expect-broken names its rules. +function check(spec, cfgFile, tlcDir, main) { + const name = path.basename(spec, ".tla"); + if (!fs.existsSync(spec)) fail(`${rel(spec)} does not exist`); + if (!fs.existsSync(cfgFile)) + fail(`${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`); + const { base, rules } = splitRules(fs.readFileSync(cfgFile, "utf8")); + const unknown = main ? [...expected].filter((r) => !rules.includes(r)) : []; + if (unknown.length) + fail( + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(cfgFile)}, whose rules are ${rules.join(", ") || "none"}.`, + ); + const dot = path.join(tlcDir, `${name}.dot`); + tlc(spec, base, path.join(tlcDir, name), dot); + console.log(`TLC on ${path.basename(spec)}:`); + if (rules.length === 0) + console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); + const traces = rules.map((rule) => { + const file = path.join(tlcDir, `${name}-${rule}`); + const broken = /^Error: Invariant /m.test(tlc(spec, `${base}INVARIANT ${rule}\n`, file)); + console.log( + ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, + ); + return { file: `${file}.out`, rule, broken }; + }); + return { spec, dot, traces, broken: traces.some((t) => t.broken) }; +} + +// Without --expect-broken, every rule must hold. With it, exactly those rules break in the input +// spec, and the other model breaks no other rule. +function rulesAsExpected(now, other) { + const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); + if (expected.size === 0) return !now.broken && !other?.broken; + const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( + (r) => !expected.has(r), + ); + const holds = [...expected].filter((r) => !brokenIn(now).includes(r)); + if (unexpected.length) console.log(`\nBroken but not expected: ${unexpected.join(", ")}.`); + if (holds.length) console.log(`\nExpected to break but holds: ${holds.join(", ")}.`); + if (!unexpected.length && !holds.length) + console.log(`\nBroken as expected: ${[...expected].join(", ")}.`); + return !unexpected.length && !holds.length; +} + +const SCAFFOLD = (name, graph) => { + const vars = Object.keys([...graph.nodes.values()][0]?.vars ?? {}); + const actions = [...new Set([...graph.edges, ...graph.loops].map((e) => e.action))].sort(); + return `// Chart labels for ${name}, for statechart.mjs. Write labels in plain words, not variable or action names. +// v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind. +// Sequences, tuples, and sets become arrays, indexed from 0. Records and functions become objects. +// Variables: ${vars.join(", ")}. + +// The actions the reader takes. facts.settled is true when only these are enabled, and the overview +// keeps the shortest path to each settled state, plus every bug path. +export const readerActions = []; + +// The label for one model state. facts.enabled is the set of actions the state enables, +// including steps that change no variable. Write it as "context: progress", such as +// "Offline: upload queued": the chart puts the states of one context in one box. +// Do not claim a result that a pending step can still change, such as "upload failed" while a +// retry is still queued. +export function state(v, facts) { + return JSON.stringify(v); +} + +// The names of the two parallel regions, when the overview draws them: contexts, then progress. +// export const regionNames = ["Network", "What happened to the file"]; + +// The group of one model state, in words the reader sees, such as "Upload done". The chart fills +// the states of one group with one color, and the legend names the groups. TLC still decides +// which states break a rule, and draws them with a thick red border on top of the group color. +// groupColors gives a group a light color. Groups without one take a palette color. +// export function group(v, facts) {} +// export const groupColors = { "Upload done": "#d1f2eb" }; + +// The label for one step. A step that leaves the reader in the same state is not drawn, +// unless the reader takes it on a bug path. "" draws no label, and a numbered bug step shows the action. +const STEPS = { +${actions.map((a) => `\t${a}: ${JSON.stringify(a)},`).join("\n")} +}; +export function step(action, before, after) { + return STEPS[action] ?? action; +} + +// When the step can happen, in plain words, such as "offline or disk full". The chart shows +// "step [words]". "" adds nothing. +// export function guard(action, before, after) {} + +// For a timer step, how many seconds the code waits, from the constant in the code. The chart +// shows "after N s, step". Return nothing for other steps. +// export function timer(action, before, after) {} + +// Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and +// the app drops an upload it cannot send". No variable, function, or event names. +// Counterexamples that break one rule with one cause are one bug, so two bugs that end on the +// same screen stay apart only if their causes differ. +// export function cause(v, facts, invariant) {} + +// The developer's words for one model state. With it, each bug is also drawn through these states. +// export function detail(v, facts) {} +`; +}; + +async function draw({ now, second, role, map, out, comparing }) { + const mapping = await import(pathToFileURL(path.resolve(map)).href); + const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; + from.push(...now.traces.flatMap((t) => ["--traces", t.file])); + if (second) { + from.push(`--${role}-dot`, second.dot, "--spec", second.spec); + from.push(...second.traces.flatMap((t) => [`--${role}-traces`, t.file])); + if (args.label !== undefined) from.push("--label", args.label); + } + // compare-charts.mjs needs every edge, every label, and no boxes, and --cover drops edges. + if (comparing) from.push("--all-labels", "--flat"); + else if (mapping.readerActions?.length) from.push("--cover"); + const titled = (suffix) => (args.title ? ["--title", `${args.title}${suffix}`] : []); + for (const f of fs.readdirSync(out)) + if (/^bug-\d+(-developer|-layered)?\.(mmd|png)$/.test(f)) fs.rmSync(path.join(out, f)); + + const files = ["chart.mmd", "stories.md", "summary.json"].map((f) => path.join(out, f)); + const proof = path.join(out, "tlc", "regions-proof.mmd"); + const everyArrow = path.join(out, "tlc", "regions-all.mmd"); + for (const f of [proof, everyArrow]) fs.rmSync(f, { force: true }); + script( + "tlc-to-chart.mjs", + [ + ...from, + ...(comparing ? [] : ["--regions", "--proof", proof, "--regions-all", everyArrow]), + ...titled(""), + "--highlight", + "0", + "--out", + files[0], + "--story", + files[1], + "--summary", + files[2], + ], + map, + ); + const summary = JSON.parse(fs.readFileSync(files[2], "utf8")); + const charts = [files[0]]; + for (const c of summary.classes) { + const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; + for (const view of views) { + const file = path.join(out, `bug-${c.n}${view}.mmd`); + const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"]; + script( + "tlc-to-chart.mjs", + [...from, ...shape, ...titled(`, bug ${c.n}`), "--highlight", String(c.n), "--out", file], + map, + ); + charts.push(file); + } + } + + const { works, wrong, fixed: fixedCount } = summary.chartStatus; + const L = summary.second?.label; + console.log( + `\nChart: ${summary.chartStates} states and ${summary.chartTransitions} transitions from ${summary.modelStates} model states.`, + ); + const r = summary.regions; + if (r?.drawn) + console.log( + `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 ${rel(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${rel(everyArrow)} has all ${r.all} arrows.`, + ); + else if (r) console.log(`It has no parallel regions, because ${r.why}.`); + const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; + console.log( + role === "fixed" + ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n` + : role === "against" + ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n` + : `${works} work, ${breaks}.\n`, + ); + if (summary.codeLike.length) + console.log( + `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`, + ); + console.log( + summary.classes.length ? fs.readFileSync(files[1], "utf8").trimEnd() : "No rule is broken.", + ); + return { files, charts }; +} + +function render(charts) { + // Mermaid shrinks a wide chart to the 800px page; keep its natural size so labels stay readable. + const config = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")), "mermaid.json"); + fs.writeFileSync(config, JSON.stringify({ state: { useMaxWidth: false } })); + const pngs = []; + for (const chart of charts) { + const png = chart.replace(/\.mmd$/, ".png"); + const r = spawnSync( + "npx", + ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2", "-c", config], + { encoding: "utf8" }, + ); + if (r.status === 0) pngs.push(png); + else + console.error( + `could not render ${rel(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, + ); + } + return pngs; +} + +function finish(out, charts, files, ok) { + const pngs = args.png ? render(charts) : []; + const written = [...charts, ...pngs, ...files.slice(1)].map((f) => path.basename(f)).join(", "); + console.log(`\nWrote ${written} in ${rel(out)}.`); + process.exit(ok ? 0 : 1); +} + +const outDir = (name) => { + const out = args.out ?? path.join(path.dirname(input), `${name}-statechart`); + fs.mkdirSync(path.join(out, "tlc"), { recursive: true }); + return out; +}; + +if (command === "from-spec") { + const name = path.basename(input, ".tla"); + const role = args.fixed ? "fixed" : args.against ? "against" : null; + const other = args.fixed ?? args.against; + // TLC's output files are named after the spec, so a second model with the same name overwrites them. + if (other && path.basename(other, ".tla") === name) + fail( + `${rel(other)} needs its own file and module name, such as ${name}${role === "fixed" ? "Fixed" : "Agreed"}.tla.`, + ); + needTlc(); + const out = outDir(name); + const map = args.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const now = check(input, input.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); + fail(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); + } + let second = null; + if (other) { + const cfg = other.replace(/\.tla$/, ".cfg"); + second = check( + other, + fs.existsSync(cfg) ? cfg : input.replace(/\.tla$/, ".cfg"), + path.join(out, "tlc"), + ); + } + const { files, charts } = await draw({ now, second, role, map, out }); + finish(out, charts, files, rulesAsExpected(now, second)); +} else { + const name = + args.module ?? + path + .basename(input, path.extname(input)) + .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase()); + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) + fail(`cannot make a TLA+ module name from ${input}; add --module NAME`); + const spec = path.join(path.dirname(input), `${name}.tla`); + if (fs.existsSync(spec)) { + console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); + } else { + script("chart-to-tla.mjs", [input, "--module", name], input); + console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); + } + needTlc(); + const out = outDir(name); + const now = check(spec, spec.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); + const { files, charts } = await draw({ + now, + map: spec.replace(/\.tla$/, ".map.mjs"), + out, + comparing: true, + }); + const compared = script("compare-charts.mjs", [input, files[0]]); + if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); + else + console.log( + `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + ); + finish(out, charts, files, rulesAsExpected(now) && compared.status === 0); +} diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 981a9ffdb..545d76a23 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -1,39 +1,59 @@ // node --test scripts/test/statechart.test.mjs -// Set TLA2TOOLS (path to tla2tools.jar) and JAVA (java binary, default "java") to run the TLC tests too. +// The TLC tests run when statechart.mjs can find TLC. Its --help says where it looks. import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { checkTlcOutput, parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; +import { + checkTlcOutput, + findTlc, + parseDot, + parseMermaid, + parseTraces, + parseValue, + ruleSentences, + stateKey, +} from "../lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const scripts = dirname(here); const fixture = (name) => readFileSync(join(here, name), "utf8"); const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); -const node = (script, args, cwd) => spawnSync(process.execPath, [join(scripts, script), ...args], { cwd, encoding: "utf8" }); +const node = (script, args, cwd, env = {}) => + spawnSync(process.execPath, [join(scripts, script), ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + }); test("a transition on a composite leaves from every state inside it", () => { - const { regions } = parseMermaid(fixture("upload.mmd")); - const main = regions.Main; - assert.deepEqual([...main.states].sort(), ["Nothing chosen", "Upload failed", "Uploaded", "Uploading.Checking the file", "Uploading.Sending the file"]); - assert.deepEqual([...main.initial], ["Nothing chosen"]); - assert.deepEqual(edges(main), [ - "Nothing chosen -> Uploading.Sending the file : you choose a file", - "Upload failed -> Uploading.Sending the file : you retry", - "Uploading.Checking the file -> Nothing chosen : you cancel", - "Uploading.Checking the file -> Upload failed : the network drops", - "Uploading.Checking the file -> Uploaded : the check passes", - "Uploading.Sending the file -> Nothing chosen : you cancel", - "Uploading.Sending the file -> Upload failed : the network drops", - "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", - ]); + const { regions } = parseMermaid(fixture("upload.mmd")); + const main = regions.Main; + assert.deepEqual([...main.states].sort(), [ + "Nothing chosen", + "Upload failed", + "Uploaded", + "Uploading.Checking the file", + "Uploading.Sending the file", + ]); + assert.deepEqual([...main.initial], ["Nothing chosen"]); + assert.deepEqual(edges(main), [ + "Nothing chosen -> Uploading.Sending the file : you choose a file", + "Upload failed -> Uploading.Sending the file : you retry", + "Uploading.Checking the file -> Nothing chosen : you cancel", + "Uploading.Checking the file -> Upload failed : the network drops", + "Uploading.Checking the file -> Uploaded : the check passes", + "Uploading.Sending the file -> Nothing chosen : you cancel", + "Uploading.Sending the file -> Upload failed : the network drops", + "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", + ]); }); test("the deepest source wins when a composite and a state inside it share a label", () => { - const { regions } = parseMermaid(`stateDiagram-v2 + const { regions } = parseMermaid(`stateDiagram-v2 state Form { [*] --> Typing Typing --> Confirm : you press Escape @@ -42,47 +62,84 @@ test("the deepest source wins when a composite and a state inside it share a lab [*] --> Form Form --> Closed : you press Escape Confirm --> Closed : you press Escape`); - assert.deepEqual(edges(regions.Main), ["Form.Confirm -> Closed : you press Escape", "Form.Typing -> Form.Confirm : you press Escape"]); + assert.deepEqual(edges(regions.Main), [ + "Form.Confirm -> Closed : you press Escape", + "Form.Typing -> Form.Confirm : you press Escape", + ]); }); test("a top-level composite split by -- makes one region per part", () => { - const { regions, nevers } = parseMermaid(fixture("sync.mmd")); - assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); - assert.deepEqual(edges(regions["Sync badge"]), [ - "Synced -> Waiting to sync : the network drops", - "Synced -> Waiting to sync : you edit a note", - "Waiting to sync -> Synced : the server confirms", - ]); - assert.deepEqual(nevers, [{ name: "NeverSyncedWhileOffline", states: ["Offline", "Synced"], sentence: 'You never see "Synced" while you are offline.' }]); + const { regions, nevers } = parseMermaid(fixture("sync.mmd")); + assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); + assert.deepEqual(edges(regions["Sync badge"]), [ + "Synced -> Waiting to sync : the network drops", + "Synced -> Waiting to sync : you edit a note", + "Waiting to sync -> Synced : the server confirms", + ]); + assert.deepEqual(nevers, [ + { + name: "NeverSyncedWhileOffline", + states: ["Offline", "Synced"], + sentence: 'You never see "Synced" while you are offline.', + }, + ]); +}); + +test("a rule's sentence is the comment lines right above it, and a state key ignores field order", () => { + const sentences = ruleSentences(`---- MODULE M ---- +\\* Not about a rule. + +\\* You never see "Saved" +\\* while the note is a draft. +NoFalseSaved == TRUE +Bare == TRUE +====`); + assert.deepEqual( + [...sentences], + [["NoFalseSaved", 'You never see "Saved" while the note is a draft.']], + ); + assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 })); }); test("the legend is not part of the machine", () => { - const { regions } = parseMermaid(`stateDiagram-v2 + const { regions } = parseMermaid(`stateDiagram-v2 state "Legend" as legend { state "Works today" as legendWorks } [*] --> A A --> B : go`); - assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); }); test("the parser refuses charts it cannot read", () => { - assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); - assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); - assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), /no \[\*\] --> start/); - assert.throws( - () => parseMermaid("stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A"), - /below the top level/, - ); - assert.throws(() => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), /final state/); - assert.throws( - () => parseMermaid('stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go'), - /two states have the caption "Something went wrong"/, - ); + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); + assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); + assert.throws( + () => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), + /no \[\*\] --> start/, + ); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A", + ), + /below the top level/, + ); + assert.throws( + () => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), + /final state/, + ); + assert.throws( + () => + parseMermaid( + 'stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go', + ), + /two states have the caption "Something went wrong"/, + ); }); test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => { - const { regions } = parseMermaid(`stateDiagram-v2 + const { regions } = parseMermaid(`stateDiagram-v2 classDef wrong fill:#fdd N : Nothing chosen N : press Upload to start @@ -96,34 +153,47 @@ test("the parser reads class shorthand, repeated descriptions, and states first Moving --> A : start Y --> Z : next A --> N : cancel`); - assert.deepEqual([...regions.Main.states].sort(), ["A.X", "A.Y", "Done", "Moving", "Nothing chosen", "Z"]); - assert.deepEqual(edges(regions.Main), [ - "A.X -> A.Y : go", - "A.X -> Nothing chosen : cancel", - "A.Y -> Nothing chosen : cancel", - "A.Y -> Z : next", - "Done -> Moving : ", - "Moving -> A.X : start", - "Nothing chosen -> Done : you finish", - ]); + assert.deepEqual([...regions.Main.states].sort(), [ + "A.X", + "A.Y", + "Done", + "Moving", + "Nothing chosen", + "Z", + ]); + assert.deepEqual(edges(regions.Main), [ + "A.X -> A.Y : go", + "A.X -> Nothing chosen : cancel", + "A.Y -> Nothing chosen : cancel", + "A.Y -> Z : next", + "Done -> Moving : ", + "Moving -> A.X : start", + "Nothing chosen -> Done : you finish", + ]); }); test("TLC values, states, graphs, and traces parse", () => { - assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); - assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); - const graph = parseDot(`strict digraph DiskGraph { + assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); + assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); + const graph = parseDot(`strict digraph DiskGraph { 1 [label="/\\\\ x = 0",style = filled] 2 [label="/\\\\ x = 1"] 1 -> 2 [label="Inc",color="black",fontcolor="black"]; 1 -> 2 [label="Inc",color="black",fontcolor="black"]; 2 -> 2 [label="Stay"]; }`); - assert.equal(graph.nodes.get("1").init, true); - assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); - assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); - assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); - assert.throws(() => parseDot('strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}'), /no action names/); - const [trace] = parseTraces(`Error: Invariant Small is violated. + assert.equal(graph.nodes.get("1").init, true); + assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); + assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); + assert.throws( + () => + parseDot( + 'strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}', + ), + /no action names/, + ); + const [trace] = parseTraces(`Error: Invariant Small is violated. Error: The behavior up to this point is: State 1: x = 0 @@ -131,8 +201,11 @@ x = 0 State 2: x = 1 `); - assert.equal(trace.invariant, "Small"); - assert.deepEqual(trace.steps, [{ action: null, vars: { x: 0 } }, { action: "Inc", vars: { x: 1 } }]); + assert.equal(trace.invariant, "Small"); + assert.deepEqual(trace.steps, [ + { action: null, vars: { x: 0 } }, + { action: "Inc", vars: { x: 1 } }, + ]); }); const done = "Model checking completed. No error has been found.\n"; @@ -158,76 +231,124 @@ s = "Saving" `; test("a rule broken by a start state is a counterexample of one state", () => { - const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: + const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: /\\ badge = "Synced" /\\ conn = "Offline" ${done}`; - assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); - assert.deepEqual(parseTraces(text), [{ invariant: "NeverSyncedWhileOffline", steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }] }]); + assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.deepEqual(parseTraces(text), [ + { + invariant: "NeverSyncedWhileOffline", + steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }], + }, + ]); }); test("TLC output from a run that failed or stopped early is refused", () => { - assert.doesNotThrow(() => checkTlcOutput(`Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, "ok.out")); - assert.throws(() => checkTlcOutput("Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", "cfg.out"), /cfg\.out: TLC stopped on an error that is not a broken rule/); - assert.throws(() => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), /not a broken rule/); - assert.throws(() => checkTlcOutput(evaluationError, "eval.out"), /TLC threw an unexpected exception/); - assert.throws(() => checkTlcOutput("Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", "stopped.out"), /did not finish/); - assert.deepEqual( - parseTraces(evaluationError).map((t) => t.invariant), - ["NoLost", null], - ); + assert.doesNotThrow(() => + checkTlcOutput( + `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, + "ok.out", + ), + ); + assert.throws( + () => + checkTlcOutput( + "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", + "cfg.out", + ), + /cfg\.out: TLC stopped on an error that is not a broken rule/, + ); + assert.throws( + () => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), + /not a broken rule/, + ); + assert.throws( + () => checkTlcOutput(evaluationError, "eval.out"), + /TLC threw an unexpected exception/, + ); + assert.throws( + () => + checkTlcOutput( + "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", + "stopped.out", + ), + /did not finish/, + ); + assert.deepEqual( + parseTraces(evaluationError).map((t) => t.invariant), + ["NoLost", null], + ); }); test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync( - join(dir, "save.dot"), - `strict digraph DiskGraph { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "save.dot"), + `strict digraph DiskGraph { 1 [label="/\\\\ s = \\"Idle\\"",style = filled] 2 [label="/\\\\ s = \\"Saving\\""] 1 -> 2 [label="Tap"]; 2 -> 2 [label="Retry"]; }`, - ); - writeFileSync( - join(dir, "save.map.mjs"), - `export const readerActions = ["Tap"]; + ); + writeFileSync( + join(dir, "save.map.mjs"), + `export const readerActions = ["Tap"]; export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving"); export const step = (action) => ({ Tap: "you tap Save" })[action] ?? ""; `, - ); - const r = node("tlc-to-chart.mjs", ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], dir); - assert.equal(r.status, 0, r.stderr); - assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), ['Badge says "Synced" -> Saving : you tap Save']); + ); + const r = node( + "tlc-to-chart.mjs", + ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), [ + 'Badge says "Synced" -> Saving : you tap Save', + ]); }); test("chart-to-tla writes one action per label and one invariant per never line", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); - const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); - assert.equal(r.status, 0, r.stderr); - const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); - assert.match(spec, /^VARIABLES connection, syncBadge$/m); - assert.match(spec, /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m); - assert.match(spec, /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m); - assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\n\\* Rules, one TLC run each: TypeOK NeverSyncedWhileOffline\n"); - assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + assert.match(spec, /^VARIABLES connection, syncBadge$/m); + assert.match( + spec, + /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m, + ); + assert.match( + spec, + /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m, + ); + assert.equal( + readFileSync(join(dir, "Sync.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); }); test("chart-to-tla refuses a never line that names no state", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "c.mmd"), "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n"); - const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); - assert.notEqual(r.status, 0); - assert.match(r.stderr, /names "C", which is not a state/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /names "C", which is not a state/); }); test("a never line names a state exactly, or every state inside a composite", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync( - join(dir, "c.mmd"), - `stateDiagram-v2 + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + `stateDiagram-v2 state "Saving" as S state "Saving..." as S2 state Busy { @@ -241,13 +362,16 @@ test("a never line names a state exactly, or every state inside a composite", () %% never NoSaving: Saving -- You never see Saving. %% never NoBusy: Busy -- You are never busy. `, - ); - const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); - assert.equal(r.status, 0, r.stderr); - const spec = readFileSync(join(dir, "C.tla"), "utf8"); - assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); - assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); - assert.match(readFileSync(join(dir, "C.map.mjs"), "utf8"), /^export const readerActions = \[\];$/m); + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "C.tla"), "utf8"); + assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); + assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); + assert.match( + readFileSync(join(dir, "C.map.mjs"), "utf8"), + /^export const readerActions = \[\];$/m, + ); }); const door = `stateDiagram-v2 @@ -262,137 +386,600 @@ const door = `stateDiagram-v2 } [*] --> Door`; const flatDoor = (extra = []) => - [ - "stateDiagram-v2", - ' state "Locked | Closed" as L_C', - ' state "Locked | Open" as L_O', - ' state "Unlocked | Closed" as U_C', - ' state "Unlocked | Open" as U_O', - " [*] --> L_C", - " L_C --> U_C : tap unlock", - " U_C --> L_C : tap lock", - " L_O --> U_O : tap unlock", - " L_C --> L_O : open the door", - " L_O --> L_C : close the door", - " U_C --> U_O : open the door", - " U_O --> U_C : close the door", - ...extra, - ].join("\n"); + [ + "stateDiagram-v2", + ' state "Locked | Closed" as L_C', + ' state "Locked | Open" as L_O', + ' state "Unlocked | Closed" as U_C', + ' state "Unlocked | Open" as U_O', + " [*] --> L_C", + " L_C --> U_C : tap unlock", + " U_C --> L_C : tap lock", + " L_O --> U_O : tap unlock", + " L_C --> L_O : open the door", + " L_O --> L_C : close the door", + " U_C --> U_O : open the door", + " U_O --> U_C : close the door", + ...extra, + ].join("\n"); test("compare reads parallel regions as one machine, so a guard across regions shows", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "agreed.mmd"), door); - writeFileSync(join(dir, "guarded.mmd"), flatDoor()); - writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); - const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); - assert.equal(differ.status, 1); - assert.match(differ.stdout, /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/); - const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); - assert.equal(same.stdout.trim(), "same", same.stdout); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + writeFileSync(join(dir, "guarded.mmd"), flatDoor()); + writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); + const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); + assert.equal(differ.status, 1); + assert.match( + differ.stdout, + /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/, + ); + const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); + assert.equal(same.stdout.trim(), "same", same.stdout); +}); + +const lamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : warm up [when the
power is on] + %% when On + Lit --> Dark : cool down [when the power is off] + %% when not On + } + } + %% never LitWhileOff: Off & Lit -- The bulb is never lit while the power is off. +`; + +test("a %% when line guards a step on the other region's states, and [words] are only its text", () => { + const { regions } = parseMermaid(lamp); + assert.deepEqual(regions.Bulb.transitions, [ + { from: "Dark", to: "Lit", label: "warm up", guard: { not: false, states: { Power: ["On"] } } }, + { + from: "Lit", + to: "Dark", + label: "cool down", + guard: { not: true, states: { Power: ["On"] } }, + }, + ]); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + writeFileSync( + join(dir, "flat.mmd"), + [ + "stateDiagram-v2", + " [*] --> OD", + ' state "Off | Dark" as OD', + ' state "On | Dark" as ND', + ' state "On | Lit" as NL', + ' state "Off | Lit" as OL', + " OD --> ND : flip the switch", + " ND --> OD : flip the switch", + " ND --> NL : warm up", + " NL --> OL : flip the switch", + " OL --> NL : flip the switch", + " OL --> OD : cool down", + ].join("\n"), + ); + const same = node("compare-charts.mjs", ["lamp.mmd", "flat.mmd"], dir); + assert.equal(same.stdout, "same\n", same.stderr); + const tla = node("chart-to-tla.mjs", ["lamp.mmd", "--module", "Lamp"], dir); + assert.equal(tla.status, 0, tla.stderr); + const spec = readFileSync(join(dir, "Lamp.tla"), "utf8"); + assert.match(spec, /\(bulb = "Dark" \/\\ power = "On" \/\\ bulb' = "Lit"\)/); + assert.match(spec, /\(bulb = "Lit" \/\\ ~\(power = "On"\) \/\\ bulb' = "Dark"\)/); + + for (const [from, to, why] of [ + [ + " %% when On\n", + " state Glow\n %% when On\n", + /must come right after a transition/, + ], + [ + "warm up [when the
power is on]", + "warm up", + /must end its label with the guard's words in \[ \]/, + ], + [" %% when On\n", " %% when Nope\n", /names Nope, which is not a state id/], + [" %% when On\n", " %% when Lit\n", /names Lit, which is in the step's own region/], + ]) + assert.throws(() => parseMermaid(lamp.replace(from, to)), why); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n [*] --> A\n A --> B : go [when C]\n %% when C\n B --> C : on\n", + ), + /the chart has none/, + ); + assert.deepEqual( + edges(parseMermaid("stateDiagram-v2\n [*] --> A\n A --> B : go [user away]\n").regions.Main), + ["A -> B : go"], + ); +}); + +const switchLamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : flip the switch [when the power is off] + %% when Off + Lit --> Dark : flip the switch + } + } +`; + +test("a label with a guarded and an unguarded move in one region writes both moves", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("chart-to-tla.mjs", ["switch.mmd", "--module", "Switch"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Switch.tla"), "utf8"); + assert.equal( + spec.slice(spec.indexOf("\\* flip the switch"), spec.indexOf("\nNext ==")), + `\\* flip the switch +FlipTheSwitch == + /\\ (power \\in {"Off", "On"} \\/ (bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) + /\\ \\/ (power = "Off" /\\ power' = "On") + \\/ (power = "On" /\\ power' = "Off") + \\/ (power \\notin {"Off", "On"} /\\ UNCHANGED power) + /\\ \\/ (bulb = "Dark" /\\ power = "Off" /\\ bulb' = "Lit") + \\/ (bulb = "Lit" /\\ bulb' = "Dark") + \\/ (~((bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) /\\ UNCHANGED bulb) +`, + ); +}); + +test("compare reads a one-region chart as the machine its steps reach, without steps that change nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const drawn = + "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy : start\n Busy --> Idle : finish\n"; + writeFileSync(join(dir, "drawn.mmd"), drawn); + writeFileSync(join(dir, "loop.mmd"), `${drawn} Idle --> Idle : refresh\n`); + writeFileSync(join(dir, "orphan.mmd"), `${drawn} Lost --> Idle : come back\n`); + const same = node("compare-charts.mjs", ["loop.mmd", "drawn.mmd"], dir); + assert.equal(same.status, 0); + assert.equal(same.stdout, "same\n"); + const orphan = node("compare-charts.mjs", ["orphan.mmd", "drawn.mmd"], dir); + assert.equal(orphan.status, 1); + assert.equal( + orphan.stdout, + "states the agreed chart's own steps never reach:\n Lost\n1 difference(s)\n", + ); }); test("a -- outside a composite is refused, not read as one region", () => { - const split = "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; - assert.throws(() => parseMermaid(split), /-- outside a composite/); - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "split.mmd"), split); - writeFileSync(join(dir, "door.mmd"), door); - const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); - assert.notEqual(tla.status, 0); - assert.match(tla.stderr, /-- outside a composite/); - for (const pair of [["split.mmd", "split.mmd"], ["door.mmd", "split.mmd"], ["split.mmd", "door.mmd"]]) { - const r = node("compare-charts.mjs", pair, dir); - assert.equal(r.status, 2, pair.join(" ")); - assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); - } + const split = + "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; + assert.throws(() => parseMermaid(split), /-- outside a composite/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "split.mmd"), split); + writeFileSync(join(dir, "door.mmd"), door); + const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); + assert.notEqual(tla.status, 0); + assert.match(tla.stderr, /-- outside a composite/); + for (const pair of [ + ["split.mmd", "split.mmd"], + ["door.mmd", "split.mmd"], + ["split.mmd", "door.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); + } }); test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "agreed.mmd"), door); - for (const pair of [["agreed.mmd", "missing.mmd"], ["missing.mmd", "agreed.mmd"]]) { - const r = node("compare-charts.mjs", pair, dir); - assert.equal(r.status, 2, pair.join(" ")); - assert.match(r.stderr, /^missing\.mmd: ENOENT/); - } - for (const [label, why] of [ - ["tap lock or …", /--all-labels/], - ["1. tap lock", /--highlight 0/], - ["tap lock (with the fix)", /without --fixed-dot/], - ["tap lock, blocked by the fix", /without --fixed-dot/], - ]) { - writeFileSync(join(dir, "actual.mmd"), flatDoor([` U_O --> L_O : ${label}`])); - const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); - assert.equal(r.status, 2, label); - assert.match(r.stderr, why); - } + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + for (const pair of [ + ["agreed.mmd", "missing.mmd"], + ["missing.mmd", "agreed.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^missing\.mmd: ENOENT/); + } + for (const [extra, why] of [ + [" U_O --> L_O : tap lock or …", /--all-labels/], + [" U_O --> L_O : 1. tap lock", /--highlight 0/], + [ + " %% second model (against): the spec", + "actual.mmd: it has a second model; draw ACTUAL without --fixed-dot or --against-dot\n", + ], + [ + ' state "Locked" as g1 {\n state "Closed" as L_C2\n }', + "actual.mmd: it has boxes; draw ACTUAL with --flat\n", + ], + ]) { + writeFileSync(join(dir, "actual.mmd"), flatDoor([extra])); + const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); + assert.equal(r.status, 2, extra); + if (typeof why === "string") assert.equal(r.stderr, why); + else assert.match(r.stderr, why); + } +}); + +const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: + https://github.com/tlaplus/tlaplus/releases +Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). +`; +const FROM_SPEC_USAGE = + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const FROM_CHART_USAGE = + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const noTlc = { TLA2TOOLS: "", JAVA: "" }; + +test("statechart.mjs writes the spec next to the chart, keeps it on the next run, and stops without TLC", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + const first = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal(first.status, 2); + assert.equal( + first.stdout, + "Wrote NotesApp.tla, NotesApp.cfg, and NotesApp.map.mjs from notes-app.mmd.\n", + ); + assert.equal(first.stderr, NO_TLC); + assert.equal( + readFileSync(join(dir, "NotesApp.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + const again = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal( + again.stdout, + "Kept NotesApp.tla. Delete it to write the spec again from notes-app.mmd.\n", + ); + const spec = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, noTlc); + assert.equal(spec.status, 2); + assert.equal(spec.stderr, NO_TLC); + const missingJar = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, { + TLA2TOOLS: "/nonexistent/tla2tools.jar", + }); + assert.equal(missingJar.status, 2); + assert.equal( + missingJar.stderr, + "TLA2TOOLS is /nonexistent/tla2tools.jar, which does not exist.\n", + ); +}); + +test("statechart.mjs names the chart it refuses and the argument it does not know", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const refused = node("statechart.mjs", ["from-chart", "c.mmd"], dir); + assert.equal(refused.status, 2); + assert.equal(refused.stdout, ""); + assert.equal(refused.stderr, 'c.mmd: never line names "C", which is not a state in the chart\n'); + const unknown = node("statechart.mjs", ["from-chart", "c.mmd", "--fixed", "CFixed.tla"], dir); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "unknown argument --fixed. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const sameName = node("statechart.mjs", ["from-spec", "C.tla", "--fixed", "fix/C.tla"], dir); + assert.equal(sameName.status, 2); + assert.equal( + sameName.stderr, + "fix/C.tla needs its own file and module name, such as CFixed.tla.\n", + ); + const sameAgreed = node("statechart.mjs", ["from-spec", "C.tla", "--against", "a/C.tla"], dir); + assert.equal( + sameAgreed.stderr, + "a/C.tla needs its own file and module name, such as CAgreed.tla.\n", + ); + for (const [extra, stderr] of [ + [ + ["--fixed", "F.tla", "--against", "A.tla"], + "use --fixed or --against, not both. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + ["--label", "the new build"], + "--label names the --fixed or --against model; it needs one of them. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + ]) { + const r = node("statechart.mjs", ["from-spec", "C.tla", ...extra], dir); + assert.equal(r.status, 2, extra.join(" ")); + assert.equal(r.stderr, stderr); + } + const valueless = node("statechart.mjs", ["from-chart", "c.mmd", "--out"], dir); + assert.equal(valueless.status, 2); + assert.equal( + valueless.stderr, + "--out needs a value. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const bare = node("statechart.mjs", [], dir); + assert.equal(bare.status, 2); + assert.equal( + bare.stderr, + `Usage: ${FROM_SPEC_USAGE} + ${FROM_CHART_USAGE} +Run node statechart.mjs --help for the flags, the output, and the exit codes. +`, + ); }); -const jar = process.env.TLA2TOOLS; -const java = process.env.JAVA ?? "java"; -const needsTlc = { skip: jar ? false : "set TLA2TOOLS to run TLC" }; - -// Without a rule, dump NAME.dot. With a rule, check it alone in NAME-RULE.out, as the SKILL.md loop does. -function tlc(dir, name, rule) { - const base = rule ? `${name}-${rule}` : name; - if (rule) writeFileSync(join(dir, `${base}.cfg`), `${readFileSync(join(dir, `${name}.cfg`), "utf8")}\nINVARIANT ${rule}\n`); - const dump = rule ? [] : ["-dump", "dot,actionlabels", `${name}.dot`]; - const r = spawnSync( - java, - ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", ...dump, "-config", `${base}.cfg`, `${name}.tla`], - { cwd: dir, encoding: "utf8" }, - ); - writeFileSync(join(dir, `${base}.out`), r.stdout); - return r; +test("every script prints its help with -h or --help, and points at it for an argument it does not know", () => { + const help = { + "statechart.mjs": [[], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-spec": [["from-spec"], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-chart": [["from-chart", "notes-app.mmd"], `Usage: ${FROM_CHART_USAGE}`], + "chart-to-tla.mjs": [[], "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"], + "tlc-to-chart.mjs": [[], "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"], + "compare-charts.mjs": [[], "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"], + }; + for (const [name, [before, usage]] of Object.entries(help)) { + const script = name.split(" ")[0]; + for (const flag of ["-h", "--help"]) { + const r = node(script, [...before, flag], here); + assert.equal(r.status, 0, `${name} ${flag}`); + assert.equal(r.stderr, ""); + assert.equal(r.stdout.split("\n")[0], usage); + assert.match(r.stdout, /\nExit codes\n {2}0 .*\n(.*\n)* {2}1 .*\n(.*\n)* {2}2 /); + } + } + const top = node("statechart.mjs", ["--help"], here).stdout.split("\n"); + assert.equal(top[1], ` ${FROM_CHART_USAGE}`); + assert.equal(top[2], " node statechart.mjs [from-spec|from-chart] --help"); + for (const flag of [ + "--fixed FIXED.tla", + "--label TEXT", + "--map FILE", + "--module NAME", + "--title TEXT", + "--out DIR", + "--png", + "-h, --help", + ]) + assert.ok( + top.some((l) => l.startsWith(` ${flag} `)), + flag, + ); + for (const [script, args, stderr] of [ + [ + "statechart.mjs", + ["from-spec", "--nope"], + "unknown argument --nope. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + "chart-to-tla.mjs", + ["--nope"], + "unknown argument --nope. Run node chart-to-tla.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--nope"], + "unknown argument --nope. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--regions"], + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--proof", "p.mmd"], + "--proof writes the regions proof; it needs --regions. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "compare-charts.mjs", + ["--nope"], + "unknown argument --nope. Run node compare-charts.mjs --help for the flags.\n", + ], + ]) { + const r = node(script, args, here); + assert.equal(r.status, 2, script); + assert.equal(r.stdout, ""); + assert.equal(r.stderr, stderr); + } +}); + +const tools = (() => { + try { + return findTlc(); + } catch { + return null; + } +})(); +const needsTlc = { skip: tools ? false : "cannot find TLC" }; + +// Check every INVARIANT in NAME.cfg in one run and dump NAME.dot. +function tlc(dir, name) { + const r = spawnSync( + tools.java, + [ + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + mkdtempSync(join(tmpdir(), "tlc-")), + "-deadlock", + "-continue", + "-dump", + "dot,actionlabels", + `${name}.dot`, + "-config", + `${name}.cfg`, + `${name}.tla`, + ], + { cwd: dir, encoding: "utf8" }, + ); + writeFileSync(join(dir, `${name}.out`), r.stdout); + return r; } -test("chart to TLA+ to chart gives back the same chart", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); - assert.equal(node("chart-to-tla.mjs", ["upload.mmd", "--module", "Upload"], dir).status, 0); - assert.match(tlc(dir, "Upload").stdout, /Model checking completed\. No error has been found\./); - const typeOk = tlc(dir, "Upload", "TypeOK"); - assert.equal(typeOk.status, 0, typeOk.stdout); - assert.doesNotMatch(typeOk.stdout, /^Error:/m); - const drawn = node("tlc-to-chart.mjs", ["--dot", "Upload.dot", "--map", "Upload.map.mjs", "--highlight", "0", "--out", "again.mmd"], dir); - assert.equal(drawn.status, 0, drawn.stderr); - const same = node("compare-charts.mjs", ["upload.mmd", "again.mmd"], dir); - assert.equal(same.stdout.trim(), "same"); +test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); + const r = node("statechart.mjs", ["from-chart", "upload.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.equal( + r.stdout, + `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd. +TLC on Upload.tla: + TypeOK holds. + +Chart: 5 states and 8 transitions from 5 model states. +5 work, 0 break a rule. + +No rule is broken. + +The spec draws upload.mmd back unchanged. + +Wrote chart.mmd, stories.md, summary.json in Upload-statechart. +`, + ); + assert.equal(r.stderr, ""); +}); + +test("TLC reads a label with a guarded and an unguarded move in one region", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("statechart.mjs", ["from-chart", "switch.mmd"], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws switch\.mmd back unchanged\.\n/); }); -test("a never line finds the design bug, and the fixed model marks it fixed", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); - assert.equal(node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir).status, 0); - const rule = "NeverSyncedWhileOffline"; - tlc(dir, "Sync"); - // With -continue, TLC exits 0 and still prints "Model checking completed" after a violation. - const broken = tlc(dir, "Sync", rule); - assert.equal(broken.status, 0); - assert.match(broken.stdout, /Invariant NeverSyncedWhileOffline is violated[^]*Model checking completed\. No error has been found\./); - - const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); - const guarded = spec.replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n').replace("MODULE Sync", "MODULE SyncFixed"); - assert.notEqual(guarded, spec); - writeFileSync(join(dir, "SyncFixed.tla"), guarded); - writeFileSync(join(dir, "SyncFixed.cfg"), readFileSync(join(dir, "Sync.cfg"))); - tlc(dir, "SyncFixed"); - assert.doesNotMatch(tlc(dir, "SyncFixed", rule).stdout, /is violated/); - - const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", `Sync-${rule}.out`, "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; - assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); - const before = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(before.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 2]]); - assert.equal(before.chartStatus.wrong, 1); - assert.match(readFileSync(join(dir, "story.md"), "utf8"), /1\. the network drops\. Now: Offline \| Waiting to sync\n2\. the server confirms\. Now: Offline \| Synced/); - assert.match(readFileSync(join(dir, "chart.mmd"), "utf8"), /: 2\. the server confirms\n/); - - const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", `SyncFixed-${rule}.out`]; - assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); - const after = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(after.chartStatus, { works: 3, wrong: 0, fixed: 1 }); - assert.equal(after.classes[0].fix, "Offline | Waiting to sync"); - assert.match(readFileSync(join(dir, "story.md"), "utf8"), /`TheServerConfirms`\) cannot happen/); +const syncStory = `## 1. Offline | Synced + +Rule broken: You never see "Synced" while you are offline. +This is the only way to get here in the model. + +0. Start: Online | Synced +1. The network drops. Now: Offline | Waiting to sync +2. The server confirms. Now: Offline | Synced +`; + +test("from-chart finds the design bug, and from-spec with the fix marks it fixed", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const today = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(today.status, 1, today.stderr); + assert.equal( + today.stdout, + `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. + +Chart: 4 states and 8 transitions from 4 model states. +3 work, 1 breaks a rule. + +${syncStory} +The spec draws sync.mmd back unchanged. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Sync-statechart", "bug-1.mmd"), "utf8"), + /: 2\. the server confirms\n/, + ); + + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + const guarded = spec + .replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n') + .replace("MODULE Sync", "MODULE SyncFixed"); + assert.notEqual(guarded, spec); + writeFileSync(join(dir, "SyncFixed.tla"), guarded); + const fixed = node("statechart.mjs", ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], dir); + assert.equal(fixed.status, 1, fixed.stderr); + assert.equal( + fixed.stdout, + `TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. +TLC on SyncFixed.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 4 states and 9 transitions from 4 model states. +3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. + +${syncStory} +With the fix, step 2 cannot happen, so you stay at: Offline | Waiting to sync. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + const summary = JSON.parse(readFileSync(join(dir, "Sync-statechart", "summary.json"), "utf8")); + assert.deepEqual(summary.chartStatus, { works: 3, wrong: 0, fixed: 1 }); + assert.equal(summary.classes[0].fix, "Offline | Waiting to sync"); + + const map = readFileSync(join(dir, "Sync.map.mjs"), "utf8"); + writeFileSync(join(dir, "Sync.map.mjs"), `${map}export const group = () => "Your notes";\n`); + const grouped = node( + "statechart.mjs", + ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], + dir, + ); + assert.equal(grouped.status, 1, grouped.stderr); + const lines = readFileSync(join(dir, "Sync-statechart", "chart.mmd"), "utf8").split("\n"); + assert.deepEqual( + lines.filter((l) => /^ {2}(classDef|class) |legend/.test(l)), + [ + " classDef plainFixed fill:#f4f6f7,stroke:#1e8449,stroke-width:4px,color:#145a32", + " classDef group0 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group0Fixed fill:#d6eaf8,stroke:#1e8449,stroke-width:4px,color:#145a32", + ' state "Legend" as legend {', + ' state "Your notes" as legendGroup0', + ' state "Fixed or new with the fix" as legendFixed', + " class legendGroup0 group0", + " class legendFixed plainFixed", + " class s0 group0Fixed", + " class s1 group0", + " class s2 group0", + " class s3 group0", + ], + ); + writeFileSync(join(dir, "Sync.map.mjs"), map); + + writeFileSync(join(dir, "Sync.tla"), guarded.replace("MODULE SyncFixed", "MODULE Sync")); + const coded = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(coded.status, 1, coded.stderr); + assert.equal( + coded.stdout, + `Kept Sync.tla. Delete it to write the spec again from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 3 states and 5 transitions from 3 model states. +3 work, 0 break a rule. + +No rule is broken. + +The spec does not draw sync.mmd: + states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them): + Offline | Synced + transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step): + Offline | Synced --> Offline | Waiting to sync : the network drops + Offline | Synced --> Offline | Waiting to sync : you edit a note + Offline | Synced --> Online | Synced : the network returns + Offline | Waiting to sync --> Offline | Synced : the server confirms + 5 difference(s) + +Wrote chart.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); }); const pay = (name, charge) => `---- MODULE ${name} ---- @@ -414,57 +1001,177 @@ export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you export const cause = () => "each charge request charges again"; `; -test("a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; - writeFileSync(join(dir, "Pay.tla"), pay("Pay", `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`)); - writeFileSync( - join(dir, "PayFixed.tla"), - pay("PayFixed", `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`), - ); - writeFileSync(join(dir, "Pay.cfg"), cfg); - writeFileSync(join(dir, "PayFixed.cfg"), cfg); - writeFileSync(join(dir, "pay.map.mjs"), payMap); - assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); - assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); - - const statusOf = (chart) => { - const names = new Map([...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [m[2], m[1].replace(//g, " ")])); - return Object.fromEntries([...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]])); - }; - const args = ["--dot", "Pay.dot", "--map", "pay.map.mjs", "--traces", "Pay.out", "--highlight", "0", "--out", "chart.mmd"]; - assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); - assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { - Cart: "works", - Paying: "works", - "Paying, charged": "works", - "Paying, charged twice": "wrong", - "Refunded, charged": "wrong", - }); - - const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; - assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); - const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); - assert.deepEqual(statusOf(chart), { - Cart: "works", - "Paid, charged": "fixed", - Paying: "works", - "Paying, charged": "works", - "Paying, charged twice": "fixed", - "Refunded, charged": "fixed", - }); - const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => t.label.endsWith("with the fix")); - assert.deepEqual( - fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), - ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], - ); -}); +test( + "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "PayFixed.tla"), + pay( + "PayFixed", + `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`, + ), + ); + writeFileSync(join(dir, "Pay.cfg"), cfg); + writeFileSync(join(dir, "PayFixed.cfg"), cfg); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); + + const statusOf = (chart) => { + const names = new Map( + [...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [ + m[2], + m[1].replace(//g, " "), + ]), + ); + return Object.fromEntries( + [...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]]), + ); + }; + const args = [ + "--dot", + "Pay.dot", + "--map", + "pay.map.mjs", + "--traces", + "Pay.out", + "--highlight", + "0", + "--out", + "chart.mmd", + ]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { + Cart: "works", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "wrong", + "Refunded, charged": "wrong", + }); + + const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); + assert.deepEqual(statusOf(chart), { + Cart: "works", + "Paid, charged": "fixed", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "fixed", + "Refunded, charged": "fixed", + }); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works today", "Fixed or new with the fix"], + ); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/); + const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => + t.label.endsWith("with the fix"), + ); + assert.deepEqual( + fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), + ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], + ); + }, +); + +test( + "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n", + ); + const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(first.status, 2); + assert.equal(first.stdout, "TLC on Pay.tla:\n NoDoubleCharge is broken.\n"); + assert.equal( + first.stderr, + "\nWrote Pay.map.mjs. Fill in its labels, then run this command again.\n", + ); + const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8"); + assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m); + assert.match( + scaffold, + /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), + "SPECIFICATION Spec\n", + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n", + ); + + writeFileSync( + join(dir, "Pay.map.mjs"), + `${payMap}export const detail = (v) => \`charges = \${v.charges}\`;\n`, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + assert.equal( + r.stdout, + `TLC on Pay.tla: + NoDoubleCharge is broken. + +Chart: 5 states and 4 transitions from 5 model states. +3 work, 2 break a rule. + +## 1. Paying, charged twice + +Rule broken: \`NoDoubleCharge\` +Why: each charge request charges again. +This is the only way to get here in the model. + +0. Start: Cart +1. You pay. Now: Paying +2. The bank charges you. Now: Paying, charged +3. The bank charges you. Now: Paying, charged twice + +Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, summary.json in Pay-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Pay-statechart", "bug-1-layered.mmd"), "utf8"), + /state "Paying, charged twice" as g\d \{\n {4}state "charges = 2" as s\d\n/, + ); + assert.deepEqual(readdirSync(join(dir, "Pay-statechart")).sort(), [ + "bug-1-developer.mmd", + "bug-1-layered.mmd", + "bug-1.mmd", + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); + }, +); test("a rule broken by a start state marks that state wrong", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync( - join(dir, "Start.tla"), - `---- MODULE Start ---- + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Start.tla"), + `---- MODULE Start ---- VARIABLES conn, badge vars == <> Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced" @@ -474,16 +1181,33 @@ Spec == Init /\\ [][Next]_vars NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced") ==== `, - ); - writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); - writeFileSync(join(dir, "start.map.mjs"), 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n'); - assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); - const args = ["--dot", "Start.dot", "--map", "start.map.mjs", "--traces", "Start.out", "--summary", "summary.json", "--out", "chart.mmd"]; - const r = node("tlc-to-chart.mjs", args, dir); - assert.equal(r.status, 0, r.stderr); - const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(summary.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 0]]); - assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); + ); + writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); + writeFileSync( + join(dir, "start.map.mjs"), + 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n', + ); + assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); + const args = [ + "--dot", + "Start.dot", + "--map", + "start.map.mjs", + "--traces", + "Start.out", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ]; + const r = node("tlc-to-chart.mjs", args, dir); + assert.equal(r.status, 0, r.stderr); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.end, c.readerSteps]), + [["Offline | Synced", 0]], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); }); const edit = (name, fixed) => `---- MODULE ${name} ---- @@ -500,30 +1224,627 @@ NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed) ==== `; -test("a bug that only the fixed model has is numbered after the current bugs and drawn wrong", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; - for (const [name, fixed] of [["Edit", false], ["EditFixed", true]]) { - writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); - writeFileSync(join(dir, `${name}.cfg`), cfg); - assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); - } - writeFileSync( - join(dir, "edit.map.mjs"), - 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', - ); - const args = ["--dot", "Edit.dot", "--map", "edit.map.mjs", "--traces", "Edit.out", "--fixed-dot", "EditFixed.dot", "--fixed-traces", "EditFixed.out"]; - const r = node("tlc-to-chart.mjs", [...args, "--cover", "--highlight", "0", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"], dir); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); - const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(summary.classes.map((c) => [c.n, c.end, c.fix]), [ - [1, "Lost edit", "Saving"], - [2, "Flash", "new with the fix"], - ]); - assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); - assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); - const two = node("tlc-to-chart.mjs", [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], dir); - assert.equal(two.status, 0, two.stderr); - assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); -}); +test( + "a bug that only the fixed model has is numbered after the current bugs and drawn wrong", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; + for (const [name, fixed] of [ + ["Edit", false], + ["EditFixed", true], + ]) { + writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); + writeFileSync(join(dir, `${name}.cfg`), cfg); + assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); + } + writeFileSync( + join(dir, "edit.map.mjs"), + 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', + ); + const args = [ + "--dot", + "Edit.dot", + "--map", + "edit.map.mjs", + "--traces", + "Edit.out", + "--fixed-dot", + "EditFixed.dot", + "--fixed-traces", + "EditFixed.out", + ]; + const r = node( + "tlc-to-chart.mjs", + [ + ...args, + "--cover", + "--highlight", + "0", + "--story", + "story.md", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.n, c.end, c.fix]), + [ + [1, "Lost edit", "Saving"], + [2, "Flash", "new with the fix"], + ], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); + const two = node( + "tlc-to-chart.mjs", + [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], + dir, + ); + assert.equal(two.status, 0, two.stderr); + assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); + }, +); + +// Each edge as "from --> to : label" with full state labels, and an arrow from a box's border +// expanded to one edge from each state in the box. +function chartEdges(mmd) { + const names = new Map(); + const members = new Map(); + let box = null; + for (const line of mmd.split("\n")) { + const opens = /^ {2}state "([^"]+)" as (g\d+) \{$/.exec(line); + const state = /^ +state "([^"]+)" as (s\d+)$/.exec(line); + const said = (s) => s.replaceAll("
", " "); + if (opens) { + box = { id: opens[2], name: said(opens[1]) }; + members.set(box.id, []); + } else if (line === " }") box = null; + else if (state) { + names.set(state[2], box ? `${box.name}: ${said(state[1])}` : said(state[1])); + if (box) members.get(box.id).push(state[2]); + } + } + return [...mmd.matchAll(/^ {2}([gs]\d+) --> (s\d+) : (.*)$/gm)] + .flatMap(([, from, to, label]) => + (members.get(from) ?? [from]).map((f) => `${names.get(f)} --> ${names.get(to)} : ${label}`), + ) + .sort(); +} + +test( + "states of one context sit in one box, and an arrow from the box's border stands for the same step from each", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Window.tla"), + `---- MODULE Window ---- +VARIABLES where, reply +vars == <> +Init == where = "Starting" /\\ reply = "none" +Boot == where = "Starting" /\\ where' = "Chat open" /\\ UNCHANGED reply +Ask == where = "Chat open" /\\ reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Close == where = "Chat open" /\\ where' = "Window closed" /\\ UNCHANGED reply +Open == where = "Window closed" /\\ where' = "Chat open" /\\ reply' = "none" +Next == Boot \\/ Ask \\/ Answer \\/ Close \\/ Open +Spec == Init /\\ [][Next]_vars +NoReplyWhileClosed == ~(where = "Window closed" /\\ reply = "shown") +==== +`, + ); + writeFileSync(join(dir, "Window.cfg"), "SPECIFICATION Spec\nINVARIANT NoReplyWhileClosed\n"); + writeFileSync( + join(dir, "window.map.mjs"), + `const R = { none: "no question", pending: "reply on its way", shown: "reply shown" }; +export const state = (v) => \`\${v.where}: \${v.where === "Starting" ? "loading" : R[v.reply]}\`; +export const step = (a) => ({ Boot: "the app starts", Ask: "you ask", Answer: "the reply arrives", Close: "you close the window", Open: "you open the window" })[a]; +`, + ); + assert.match(tlc(dir, "Window").stdout, /Invariant NoReplyWhileClosed is violated/); + const args = ["--dot", "Window.dot", "--map", "window.map.mjs", "--traces", "Window.out"]; + const draw = (extra, out) => { + const r = node("tlc-to-chart.mjs", [...args, ...extra, "--out", out], dir); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, out), "utf8"); + }; + const boxed = draw([], "boxed.mmd"); + const flat = draw(["--flat"], "flat.mmd"); + + assert.match( + boxed, + /^ {2}state "Chat open" as g0 \{\n {4}state "no question" as s0\n {4}state "reply on its way" as s1\n {4}state "reply shown" as s2\n {2}\}\n {2}state "Window closed" as g1 \{\n {4}state "no question" as s3\n {4}state "reply on its way" as s4\n {4}state "reply shown" as s5\n {2}\}\n {2}state "Starting: loading" as s6\n/m, + ); + assert.deepEqual( + boxed.split("\n").filter((l) => /you open the window/.test(l)), + [" g1 --> s0 : you open the window"], + ); + assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3); + assert.doesNotMatch(flat, /as g\d+ \{/); + assert.deepEqual(chartEdges(boxed), chartEdges(flat)); + assert.match(boxed, /^ {2}class s5 wrongPath$/m); + assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m); + assert.match(boxed, /state "Breaks a rule" as legendWrong/); + }, +); + +const save = (name, code) => `---- MODULE ${name} ---- +VARIABLES screen, note${code ? ", retries" : ""} +vars == <> +Init == screen = "Editing" /\\ note = [text |-> "draft"${code ? `, rev |-> 0] /\\ retries = 0` : "]"} +Save == screen = "Editing" /\\ screen' = "Saving" /\\ UNCHANGED <> +Confirm == screen = "Saving" /\\ screen' = "Saved" /\\ note' = [text |-> "stored"${code ? `, rev |-> 1] /\\ UNCHANGED retries` : "]"} +Edit == screen = "Saved" /\\ screen' = "Editing" /\\ note' = [note EXCEPT !.text = "draft"]${code ? " /\\ retries' = 0" : ""} +${ + code + ? `Retry == screen = "Saving" /\\ retries = 0 /\\ retries' = 1 /\\ UNCHANGED <> +TimeOut == screen = "Saving" /\\ retries = 1 /\\ screen' = "Saved" /\\ UNCHANGED <> +Next == Save \\/ Retry \\/ Confirm \\/ TimeOut \\/ Edit` + : "Next == Save \\/ Confirm \\/ Edit" +} +Spec == Init /\\ [][Next]_vars +TypeOK == screen \\in {"Editing", "Saving", "Saved"} +\\* You never see "Saved" while the note is still a draft. +NoFalseSaved == ~(screen = "Saved" /\\ note.text = "draft") +==== +`; + +test( + "from-spec --against compares the code with the spec on the parts both have, and --expect-broken sets the exit code", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "SaveCode.tla"), save("SaveCode", true)); + writeFileSync(join(dir, "SaveAgreed.tla"), save("SaveAgreed", false)); + writeFileSync( + join(dir, "SaveCode.cfg"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\n", + ); + writeFileSync( + join(dir, "SaveCode.map.mjs"), + `export const readerActions = ["Save", "Edit"]; +export const state = (v) => \`\${v.screen}, \${v.note.text}\`; +export const step = (a) => ({ Save: "you press Save", Retry: "the app tries again", Confirm: "the server stores the note", TimeOut: "the save times out", Edit: "you edit the note" })[a] ?? ""; +export const cause = () => "the app stops after one retry and shows Saved anyway"; +`, + ); + const run = (...extra) => + node( + "statechart.mjs", + [ + "from-spec", + "SaveCode.tla", + "--against", + "SaveAgreed.tla", + "--label", + "the agreed chart", + ...extra, + ], + dir, + ); + + const unknown = run("--expect-broken", "NoLostNote"); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "--expect-broken NoLostNote: not a rule in SaveCode.cfg, whose rules are TypeOK, NoFalseSaved.\n", + ); + + const expected = run("--expect-broken", "NoFalseSaved"); + assert.equal(expected.status, 0, expected.stderr); + assert.equal( + expected.stdout, + `TLC on SaveCode.tla: + TypeOK holds. + NoFalseSaved is broken (expected). +TLC on SaveAgreed.tla: + TypeOK holds. + NoFalseSaved holds. + +Chart: 4 states and 4 transitions from 10 model states. +3 work, 1 breaks a rule, 0 only in the agreed chart. + +## 1. Saved, draft + +Rule broken: You never see "Saved" while the note is still a draft. +Why: the app stops after one retry and shows Saved anyway. +There are 2 ways to get here in the model. This is the shortest. + +0. Start: Editing, draft +1. You press Save. Now: Saving, draft +2. The save times out. Now: Saved, draft + +In the agreed chart, step 2 cannot happen, so you stay at: Saving, draft. + +Broken as expected: NoFalseSaved. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. +`, + ); + const out = join(dir, "SaveCode-statechart"); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works", "Breaks a rule"], + ); + assert.match(chart, /^ {2}s3 --> s3 : the save times out, not in theagreed chart$/m); + const bug = readFileSync(join(out, "bug-1.mmd"), "utf8"); + assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [ + "Editing, draft", + "Saved, draft", + "Saving, draft", + ]); + const summary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8")); + assert.deepEqual(summary.second.role, "against"); + assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]); + assert.equal(summary.classes[0].against, "Saving, draft"); + assert.deepEqual(summary.codeLike, []); + + const unexpected = run(); + assert.equal(unexpected.status, 1, unexpected.stderr); + const holds = run("--expect-broken", "TypeOK"); + assert.equal(holds.status, 1, holds.stderr); + assert.match( + holds.stdout, + /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/, + ); + + writeFileSync( + join(dir, "code.map.mjs"), + readFileSync(join(out, "../SaveCode.map.mjs"), "utf8").replace( + '"the app stops after one retry and shows Saved anyway"', + '"retryCount hit maxRetries"', + ), + ); + const coded = run("--map", "code.map.mjs", "--expect-broken", "NoFalseSaved"); + assert.equal(coded.status, 0, coded.stderr); + assert.match( + coded.stdout, + /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/, + ); + }, +); + +test( + "from-chart checks a chart whose regions guard each other, and draws it back unchanged", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + const r = node( + "statechart.mjs", + ["from-chart", "lamp.mmd", "--expect-broken", "LitWhileOff"], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws lamp\.mmd back unchanged\.\n/); + }, +); + +const doorSpec = `---- MODULE Door ---- +VARIABLES where, reply +vars == <> +Init == where = "Open" /\\ reply = "none" +Ask == reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == where = "Open" /\\ reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Drop == where = "Closed" /\\ reply = "pending" /\\ reply' = "lost" /\\ UNCHANGED where +Close == where = "Open" /\\ where' = "Closed" /\\ UNCHANGED reply +Reopen == where = "Closed" /\\ where' = "Open" /\\ (reply' = reply \\/ (reply = "pending" /\\ reply' = "shown")) +Next == Ask \\/ Answer \\/ Drop \\/ Close \\/ Reopen +Spec == Init /\\ [][Next]_vars +NoLostReply == reply # "lost" +NoShownWhileClosed == ~(where = "Closed" /\\ reply = "shown") +==== +`; + +const doorMap = `const W = { Open: "Window open", Closed: "Window closed" }; +const R = { none: "no question", pending: "reply on its way", shown: "reply shown", lost: "reply lost" }; +export const regionNames = ["Where you are", "Your reply"]; +export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`; +export const step = (a) => ({ Ask: "you ask", Answer: "the reply arrives", Drop: "the app drops the reply", Close: "you close the window", Reopen: "you open the window" })[a]; +`; + +// Checks Door.tla with one rule, which breaks, and returns what from-spec printed and drew. +function runDoor(map) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.map.mjs"), map); + const out = join(dir, "Door-statechart"); + const read = (f) => readFileSync(join(out, f), "utf8").replaceAll("
", " "); + return (rule) => { + writeFileSync(join(dir, "Door.cfg"), `SPECIFICATION Spec\nINVARIANT ${rule}\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla", "--expect-broken", rule], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + const every = join(out, "tlc", "regions-all.mmd"); + return { + stdout: r.stdout, + chart: read("chart.mmd"), + every: existsSync(every) ? read("tlc/regions-all.mmd") : null, + bug: read("bug-1.mmd"), + out, + }; + }; +} + +test( + "from-spec draws parallel regions only when they combine back into TLC's graph, and boxes otherwise", + needsTlc, + () => { + const run = runDoor(doorMap); + const proved = run("NoLostReply"); + assert.match( + proved.stdout, + /\nChart: 6 states and 5 transitions from 8 model states\.\nIt has 2 parallel regions, of 2 and 4 states\. Proof: their 7 arrows together make exactly the 8 states and 13 labeled steps of Door-statechart\/tlc\/regions-proof\.mmd, and the 5 drawn arrows make only steps among them\. Door-statechart\/tlc\/regions-all\.mmd has all 7 arrows\.\n5 work, 1 breaks a rule\.\n/, + ); + const lines = proved.chart.split("\n"); + assert.ok(lines.includes(' state "Where you are" as region0 {')); + assert.ok(lines.includes(" --")); + const guarded = lines.findIndex((l) => /: the reply arrives \[when Window open\]$/.test(l)); + assert.match(lines[guarded + 1], /^ {6}%% when r0s\d$/); + assert.doesNotMatch(proved.chart, /^ {6}r1s\d --> r1s\d : you open the window/m); + assert.equal( + proved.every + .split("\n") + .filter((l) => /^ {6}(r1s\d) --> \1 : you open the window \[when Window closed\]$/.test(l)) + .length, + 1, + ); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const boxed = run("NoShownWhileClosed"); + assert.equal(boxed.every, null); + assert.match( + boxed.stdout, + /\nIt has no parallel regions, because "Window closed: reply shown" is "Breaks a rule", and its two region states would show "Works"\.\n/, + ); + assert.doesNotMatch(boxed.chart, /^ +--$/m); + assert.match(boxed.chart, /^ {2}state "Window closed" as g\d \{$/m); + }, +); + +test( + "the mapping's guard words show on an arrow only when every model step behind it has them", + needsTlc, + () => { + const run = runDoor( + `${doorMap}export const guard = (a, before) => + ({ Answer: "window open", Drop: "window closed", Ask: before.where === "Open" ? "window open" : "" })[a] ?? ""; +`, + ); + const proved = run("NoLostReply"); + const lines = proved.chart.split("\n"); + for (const words of [ + /: the reply arrives \[window open\]$/, + /: the app drops the reply \[window closed\]$/, + ]) + assert.match(lines[lines.findIndex((l) => words.test(l)) + 1], /^ {6}%% when r0s\d$/); + assert.ok(lines.some((l) => /^ {6}r1s\d --> r1s\d : you ask$/.test(l))); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + assert.match(proved.bug, / : \d\. the app drops the reply \[window closed\]$/m); + + const boxed = run("NoShownWhileClosed"); + assert.match(boxed.chart, / : you ask \[window open\]$/m); + assert.match(boxed.chart, / : you ask$/m); + }, +); + +test( + "the mapping's timer() writes after N s on timer steps, and refuses a value that is not seconds", + needsTlc, + () => { + const timed = runDoor( + `${doorMap}export const timer = (a) => (a === "Drop" ? 30 : undefined);\n`, + )("NoLostReply"); + assert.match(timed.chart, / : after 30 s, the app drops the reply \[when Window closed\]$/m); + assert.match(timed.bug, / : \d\. after 30 s, the app drops the reply$/m); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + timed.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.cfg"), "SPECIFICATION Spec\nINVARIANT NoLostReply\n"); + writeFileSync(join(dir, "Door.map.mjs"), `${doorMap}export const timer = () => "soon";\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla"], dir); + assert.notEqual(r.status, 0); + assert.match( + r.stderr, + /timer\(\) must return a number of seconds above 0, or nothing; got "soon" for \w+/, + ); + }, +); + +const groupMap = `${doorMap}export const group = (v) => + ({ none: "Nothing to show", pending: "Waiting", shown: "Reply shown", lost: "Reply shown" })[v.reply]; +export const groupColors = { "Reply shown": "#d1f2eb", Waiting: null, "Never drawn": "#ffffff" }; +`; + +test( + "group() fills each state with its group's color, and the legend lists only the groups the chart draws", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const lines = run.chart.split("\n"); + assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [ + "stateDiagram-v2", + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group0 fill:#d1f2eb,stroke:#697976,color:#1b2631", + " classDef group0Wrong fill:#d1f2eb,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group1 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group2 fill:#fdebd0,stroke:#7f7668,color:#1b2631", + ' state "Legend" as legend {', + ' state "Reply shown" as legendGroup0', + ' state "Waiting" as legendGroup1', + ' state "Nothing to show" as legendGroup2', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendGroup1 group1", + " class legendGroup2 group2", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(' state "reply lost" as r1s1')); + assert.deepEqual( + lines.filter((l) => l.startsWith(" class r")), + [ + " class r0s0 plain", + " class r0s1 plain", + " class r1s0 group2", + " class r1s1 group0Wrong", + " class r1s2 group1", + " class r1s3 group0", + ], + ); + assert.match(run.stdout, /\n5 work, 1 breaks a rule\.\n/); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + run.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const bug = run.bug.split("\n"); + assert.ok( + bug.includes( + " classDef group1Path fill:#d6eaf8,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + ), + ); + assert.deepEqual( + bug.filter((l) => /^ {2}class s\d/.test(l)), + [ + " class s0 group0Wrong", + " class s1 group1Path", + " class s2 group2Path", + " class s3 group1Path", + ], + ); + + const summary = JSON.parse(readFileSync(join(run.out, "summary.json"), "utf8")); + assert.deepEqual(summary.chartGroups, [ + { name: "Reply shown", color: "#d1f2eb", states: 2 }, + { name: "Waiting", color: "#d6eaf8", states: 1 }, + { name: "Nothing to show", color: "#fdebd0", states: 1 }, + ]); + assert.deepEqual(summary.mixedGroups, []); + }, +); + +test( + "the mapping cannot hide a broken rule, and refuses a status name, a dark or bad color, or a group that is not a name", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const tlcDir = join(run.out, "tlc"); + const chart = (map, ...extra) => { + writeFileSync(join(tlcDir, "probe.map.mjs"), map); + return node( + "tlc-to-chart.mjs", + [ + "--dot", + "Door.dot", + "--map", + "probe.map.mjs", + "--traces", + "Door-NoLostReply.out", + "--highlight", + "0", + ...extra, + ], + tlcDir, + ); + }; + + const hidden = chart( + `${doorMap}export const group = () => "All good";\nexport const groupColors = { "All good": "#e9f7ef" };\n`, + "--regions", + ); + assert.equal(hidden.status, 0, hidden.stderr); + const lines = hidden.stdout.split("\n"); + assert.ok( + lines.includes( + " classDef group0Wrong fill:#e9f7ef,stroke:#c0392b,stroke-width:4px,color:#7b241c", + ), + ); + const legend = lines.indexOf(' state "Legend" as legend {'); + assert.deepEqual(lines.slice(legend, legend + 6), [ + ' state "Legend" as legend {', + ' state "All good" as legendGroup0', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(" class r1s1 group0Wrong")); + assert.equal(lines.filter((l) => / group0Wrong$/.test(l)).length, 1); + + const coarse = chart( + `const W = { Open: "Window open", Closed: "Window closed" }; +export const state = (v) => W[v.where]; +export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); +`, + "--summary", + "coarse.json", + ); + assert.equal(coarse.status, 0, coarse.stderr); + assert.deepEqual( + coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)), + [ + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " class legendWrong plainWrong", + " class s0 plainWrong", + " class s1 plainWrong", + ], + ); + const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8")); + assert.deepEqual(summary.chartGroups, []); + assert.deepEqual(summary.mixedGroups, ["Window closed", "Window open"]); + + for (const [map, error] of [ + [ + `export const group = (v) => (v.reply === "lost" ? "Breaks a rule" : "Fine");`, + /group "Breaks a rule" has the name of a status; TLC decides the status, so name the group for what the reader sees/, + ], + [`export const group = () => "fixed";`, /group "fixed" has the name of a status/], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "#123" };`, + /groupColors\["All good"\] "#123" is too dark for the status borders to show on it/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "red" };`, + /groupColors\["All good"\] "red" is not "#rgb" or "#rrggbb"/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = ["#ffffff"];`, + /groupColors must be an object from a group name to a color such as #d6eaf8/, + ], + [`export const group = () => 1;`, /group\(\) must return a group name or nothing; got 1/], + ]) { + const r = chart(`${doorMap}${map}\n`, "--regions"); + assert.equal(r.status, 1, map); + assert.equal(r.stdout, "", map); + assert.match(r.stderr, error); + } + }, +); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs index 0a3ffdb42..d5a3f4c33 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs @@ -1,451 +1,1309 @@ #!/usr/bin/env node -// Collapse a TLC state graph onto the reader's states and write a Mermaid stateDiagram-v2. -// -// node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [options] -// -// --dot FILE state graph from TLC -dump dot,actionlabels FILE (the current code) -// --map FILE mapping module: state(vars, facts), optional detail(vars, facts), -// step(action, before, after), cause(vars, facts, invariant) (the hidden reason a bad state is bad; -// counterexamples with the same invariant and cause are one bug), and -// readerActions (the actions the reader takes). -// facts.enabled is the set of actions the state enables; facts.settled is true when -// only reader actions are enabled, so nothing else happens until the reader acts. -// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state). -// A file from a TLC run that stopped early or hit an error other than a broken rule is refused. -// --fixed-dot FILE state graph of the model with the open fix; counterexamples replay against it -// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still -// wrong, and adds the bugs that only the fixed model has after the current model's bugs -// --view NAME customer (state labels), developer (detail labels), or layered (detail inside state) -// --cover keep only the shortest path to each settled reader state, plus counterexample paths -// --highlight N draw counterexample class N as a numbered path (default 1; 0 for none) -// --path-only draw only the highlighted path -// --all-labels write every label on an edge (default: the two most common, then " or …") -// --title TEXT chart title -// --out FILE Mermaid output (default stdout) -// --story FILE reader stories, one per counterexample class -// --summary FILE JSON counts for the chart and the classes import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { LEGEND_ID, checkTlcOutput, parseDot, parseTraces, quote, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; +import { + GROUP_PALETTE, + LEGEND_ID, + PLAIN_FILL, + checkTlcOutput, + compareCharts, + groupClassDef, + groupColorProblem, + isRecord, + parseDot, + parseMermaid, + parseTraces, + productOf, + quote, + ruleSentences, + stateKey, + wrap, + STATUS_CLASSES, +} from "./lib.mjs"; -const args = { traces: [], fixedTraces: [], view: "customer", highlight: 1 }; +const USAGE = "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"; +const HELP = `${USAGE} + +Collapses a TLC state graph onto the reader's states and writes a Mermaid stateDiagram-v2. +statechart.mjs runs it once for the overview and once for each bug chart. + + --dot FILE The state graph from TLC -dump dot,actionlabels FILE, for the current code. + Required. + --map FILE The mapping module. Required. It exports state(vars, facts), and + optionally detail(vars, facts), step(action, before, after), + guard(action, before, after), timer(action, before, after), + cause(vars, facts, invariant), group(vars, facts), groupColors, + regionNames, and readerActions. guard is when the step + can happen, in plain words. An edge reads "step [words]" when every + model step behind it has words. timer is how many seconds the code waits + before a timer step, and the step reads "after N s, step". + cause is the hidden reason a bad state is bad: counterexamples with the + same invariant and cause are one bug. readerActions are the actions the + reader takes. group is the group of a state, below. + facts.enabled is the set of actions the state enables. facts.settled is + true when only reader actions are enabled, so nothing else happens until + the reader acts. + --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one + per bad state. It refuses a file from a TLC run that stopped early or hit + an error other than a broken rule. + --fixed-dot FILE The state graph of the model with the open fix. Counterexamples replay on + it. + --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks + states fixed or still wrong, and adds the bugs that only the fixed model + has after the current model's bugs. + --against-dot FILE The state graph of the spec that the current code must follow. + Counterexamples replay on it. Do not use it with --fixed-dot. + --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the + bugs that only the spec has number after the code's bugs. + --label TEXT The name of the second model in the chart and the stories. Default: + "the fix" with --fixed-dot, "the spec" with --against-dot. + --spec FILE A TLA+ file whose \\* comment lines above a rule are the rule's sentence in + the stories. Repeat it. The first file that has a sentence wins. + --view NAME customer (state labels, the default), developer (detail labels), or + layered (detail inside state). + --cover Keep only the shortest path to each settled reader state, plus the + counterexample paths. + --highlight N Draw bug N as a numbered path. Default 1. 0 draws none. + --path-only Draw only the highlighted path. + --all-labels Write every label on an edge. Default: the two most common, then " or …". + --flat Draw every state on its own, with no boxes. + --regions When every state reads "context: progress", draw two parallel regions, + context and progress, when they are smaller and proved. They draw the + steps of the shortest way to each region state, of each bug path, and + the nearest step with each label. + Otherwise draw the chart the other flags ask for, and print why. + Customer view, --highlight 0, no --flat or --path-only. + --proof FILE With --regions, write the flat chart that the proof compares with. + --regions-all FILE With --regions, also write the regions with every step. + --title TEXT The chart title. + --out FILE The Mermaid chart. Default: stdout. + --story FILE The reader stories, one per bug. + --summary FILE The counts for the chart and the bugs, as JSON. + -h, --help Print this help, and exit 0. + +The second model may have other variables and actions than the current model. Variables and +record fields that one start state has and the other does not are left out when the two models +are compared. The second model's states take the current model's start values for the variables +it does not have, so one mapping draws both. A step of an action that the second model does not +have is skipped when it changes no shared variable, and matches any step to the same shared state +when it does. + +In the customer view, states whose labels read "context: progress" with the same context sit in +one box named context, and each shows only its progress. A context with one state stays a plain +state. An arrow from a box's border stands for the same step from every state in the box: it is +drawn only when every state in the box takes that step, with the same labels, to the same state. +The layered view puts each detail state in a box named by its state label. + +With --regions, each "context: progress" label is one state in the context region and one in the +progress region. A step that happens beside only some states of the other region gets a guard: +"[when A or B]" or "[unless A or B]", whichever is shorter, and a "%% when [not] ids" line under it. +The mapping's guard words, when the step has them, replace "when A or B" in the label. +The proof combines the regions with every step as compare-charts.mjs does, and needs exactly the +flat chart's states, start states, and labeled steps. Each state's color must be the worse color +of its two region states. Then the drawn arrows, combined, must make only steps of the flat chart: +a step that they make wrongly gets back every arrow that the full regions take there. +compare-charts.mjs on the --regions-all and --proof files repeats the first proof. +mapping.regionNames = [context, progress] names the two regions. + +Without group(), the legend shows only the statuses the chart uses. A chart with a second model +has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. + +Groups: group(vars, facts) returns the name of the group that a model state is in, such as +"Toast shown", or nothing. A chart state is in a group when all its model states are in it. A +region state is in a group when every state that it is part of is in it. Each group fills its +states with one color, and the legend lists only the groups that the chart draws: first the +groups in groupColors, in that order, then the others from A to Z. groupColors, such as +{ "Toast shown": "#d6eaf8" }, gives a group a light "#rgb" or "#rrggbb" color. A group with no +color, or null, takes the next free palette color: light blue, orange, purple, yellow, teal, tan, +then pink. A state in no group, or in more than one, is gray. summary.json counts the drawn +states of each group in chartGroups, and lists the chart states with more than one in mixedGroups. +TLC decides the status, and a group cannot change it. On top of the group color, a state that +breaks a rule has a thick red border, and a fixed state or a state only in the second model a +thick green border. The legend then shows that border. A thick blue border marks the bug path on +states that work. On other states of the path, the numbered arrows show it. A group cannot have +the name of a status, such as "Breaks a rule". + +Writes only the files that --out, --story, and --summary name. + +Exit codes + 0 It wrote the chart. + 1 It cannot use an input: a mapping without state(), TLC output that it refuses, or a + --highlight with no such bug. The error says why. + 2 A bad argument.`; +const more = "Run node tlc-to-chart.mjs --help for the flags."; + +const args = { + traces: [], + fixedTraces: [], + againstTraces: [], + specs: [], + view: "customer", + highlight: 1, +}; for (let i = 2; i < process.argv.length; i++) { - const a = process.argv[i]; - const next = () => process.argv[++i]; - if (a === "--dot") args.dot = next(); - else if (a === "--map") args.map = next(); - else if (a === "--traces") args.traces.push(next()); - else if (a === "--fixed-dot") args.fixedDot = next(); - else if (a === "--fixed-traces") args.fixedTraces.push(next()); - else if (a === "--view") args.view = next(); - else if (a === "--highlight") args.highlight = Number(next()); - else if (a === "--path-only") args.pathOnly = true; - else if (a === "--all-labels") args.allLabels = true; - else if (a === "--cover") args.cover = true; - else if (a === "--title") args.title = next(); - else if (a === "--out") args.out = next(); - else if (a === "--story") args.story = next(); - else if (a === "--summary") args.summary = next(); - else { - console.error(`unknown argument ${a}`); - process.exit(2); - } -} -if (!args.dot || !args.map) { - console.error("Usage: node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [--traces tlc.out] [--fixed-dot fixed.dot] [--view customer|developer|layered]"); - process.exit(2); -} -if (!["customer", "developer", "layered"].includes(args.view)) { - console.error(`--view must be customer, developer, or layered, not ${args.view}`); - process.exit(2); + const a = process.argv[i]; + const next = () => process.argv[++i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--dot") args.dot = next(); + else if (a === "--map") args.map = next(); + else if (a === "--traces") args.traces.push(next()); + else if (a === "--fixed-dot") args.fixedDot = next(); + else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--against-dot") args.againstDot = next(); + else if (a === "--against-traces") args.againstTraces.push(next()); + else if (a === "--label") args.label = next(); + else if (a === "--spec") args.specs.push(next()); + else if (a === "--view") args.view = next(); + else if (a === "--highlight") args.highlight = Number(next()); + else if (a === "--path-only") args.pathOnly = true; + else if (a === "--all-labels") args.allLabels = true; + else if (a === "--flat") args.flat = true; + else if (a === "--regions") args.regions = true; + else if (a === "--proof") args.proof = next(); + else if (a === "--regions-all") args.regionsAll = next(); + else if (a === "--cover") args.cover = true; + else if (a === "--title") args.title = next(); + else if (a === "--out") args.out = next(); + else if (a === "--story") args.story = next(); + else if (a === "--summary") args.summary = next(); + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } } +const fail = (message) => { + console.error(`${message} ${more}`); + process.exit(2); +}; +if (!args.dot || !args.map) fail(USAGE); +if (!["customer", "developer", "layered"].includes(args.view)) + fail(`--view must be customer, developer, or layered, not ${args.view}.`); +if (args.fixedDot && args.againstDot) fail("use --fixed-dot or --against-dot, not both."); +if (args.fixedTraces.length && !args.fixedDot) fail("--fixed-traces needs --fixed-dot."); +if (args.againstTraces.length && !args.againstDot) fail("--against-traces needs --against-dot."); +if (args.label !== undefined && !args.fixedDot && !args.againstDot) + fail("--label names the second model; it needs --fixed-dot or --against-dot."); +if (args.proof && !args.regions) fail("--proof writes the regions proof; it needs --regions."); +if (args.regionsAll && !args.regions) + fail("--regions-all writes every regions arrow; it needs --regions."); +if ( + args.regions && + (args.view !== "customer" || args.flat || args.pathOnly || args.highlight !== 0) +) + fail( + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only.", + ); + +const role = args.fixedDot ? "fix" : args.againstDot ? "against" : null; +const L = args.label ?? (role === "fix" ? "the fix" : "the spec"); +const within = role === "fix" ? "with" : "in"; +const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); const mapping = await import(pathToFileURL(path.resolve(args.map)).href); -if (typeof mapping.state !== "function") throw new Error("the mapping module must export state(vars)"); -if (args.view !== "customer" && typeof mapping.detail !== "function") throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); +if (typeof mapping.state !== "function") + throw new Error("the mapping module must export state(vars)"); +if (args.view !== "customer" && typeof mapping.detail !== "function") + throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); const SEP = "\u0000"; +const rules = new Map(); +for (const f of args.specs) + for (const [name, sentence] of ruleSentences(fs.readFileSync(f, "utf8"))) + if (!rules.has(name)) rules.set(name, sentence); + const readerActions = new Set(mapping.readerActions ?? []); -const factsFrom = (enabled) => ({ enabled, settled: [...enabled].every((a) => readerActions.has(a)) }); -let factsOf = () => factsFrom(new Set()); -function keyOf(vars) { - const facts = factsOf(vars); - if (args.view === "developer") return String(mapping.detail(vars, facts)); - const outer = mapping.state(vars, facts); - if (typeof outer !== "string") throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); - return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; +const factsFrom = (enabled) => ({ + enabled, + settled: [...enabled].every((a) => readerActions.has(a)), +}); +function keyOf(vars, facts) { + if (args.view === "developer") return String(mapping.detail(vars, facts)); + const outer = mapping.state(vars, facts); + if (typeof outer !== "string") + throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); + return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; } function stepLabel(action, before, after) { - const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; - return args.view === "customer" || said === action ? said : `${said} [${action}]`; -} - -function quotient(graph) { - const keys = new Map(); - for (const n of graph.nodes.values()) keys.set(n.id, keyOf(n.vars)); - const nodes = new Set(keys.values()); - const initial = new Set([...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id))); - const edges = new Map(); - for (const e of graph.edges) { - const from = keys.get(e.from); - const to = keys.get(e.to); - if (from === to) continue; - const id = `${from}${SEP}${SEP}${to}`; - if (!edges.has(id)) edges.set(id, { from, to, counts: new Map() }); - const label = stepLabel(e.action, graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars); - if (label) edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); - } - // Most common wording first. A chart edge shows two unless --all-labels. - for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); - const settled = new Set([...graph.nodes.values()].filter((n) => factsOf(n.vars).settled).map((n) => keys.get(n.id))); - return { nodes, initial, edges, keys, settled }; -} - -function enabledIndex(g) { - const byId = new Map([...g.nodes.keys()].map((id) => [id, new Set()])); - for (const e of [...g.edges, ...g.loops]) byId.get(e.from).add(e.action); - return new Map([...g.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); -} -function useFacts(index) { - factsOf = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set()); + const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; + const named = args.view === "customer" || said === action ? said : `${said} [${action}]`; + const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null; + if (seconds === undefined || seconds === null || seconds === "" || !named) return named; + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) + throw new Error( + `timer() must return a number of seconds above 0, or nothing; got ${JSON.stringify(seconds)} for ${action}`, + ); + return `after ${seconds} s, ${named}`; +} +function stepGuard(action, before, after) { + if (typeof mapping.guard !== "function") return ""; + return String(mapping.guard(action, before, after) ?? "") + .replace(/[[\]]/g, "") + .trim(); +} +// A label shows guard words only when every model step behind it has some. +function guarded(guards, label) { + const words = guards?.get(label); + return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label; } +const addGuard = (guards, label, words) => { + if (!guards.has(label)) guards.set(label, new Set()); + guards.get(label).add(words); +}; -const graph = parseDot(fs.readFileSync(args.dot, "utf8")); -const graphIndex = enabledIndex(graph); -useFacts(graphIndex); -const now = quotient(graph); -const fixedGraph = args.fixedDot ? parseDot(fs.readFileSync(args.fixedDot, "utf8")) : null; -const fixedIndex = fixedGraph ? enabledIndex(fixedGraph) : null; -let fixed = null; -if (fixedGraph) { - useFacts(fixedIndex); - fixed = quotient(fixedGraph); - useFacts(graphIndex); +// ---------- projection between two models ---------- +// Paths of record fields that a has and b does not. It looks inside records only: sets and +// sequences can be empty in a start state, so they show no fields. +function lacking(a, b, prefix = []) { + return Object.keys(a).flatMap((k) => + !(k in b) + ? [[...prefix, k]] + : isRecord(a[k]) && isRecord(b[k]) + ? lacking(a[k], b[k], [...prefix, k]) + : [], + ); +} +const holder = (o, p) => p.slice(0, -1).reduce((x, k) => (isRecord(x?.[k]) ? x[k] : null), o); +function omit(vars, paths) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const o = holder(out, p); + if (o) delete o[p.at(-1)]; + } + return out; +} +function fill(vars, paths, from) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const [o, f] = [holder(out, p), holder(from, p)]; + if (o && f && p.at(-1) in f) o[p.at(-1)] = structuredClone(f[p.at(-1)]); + } + return out; } -const customerKey = (vars) => String(mapping.state(vars, factsOf(vars))); +// One model: its graph, the facts of each state, and its reader keys. +// lookup turns a state as the chart sees it back into the model's own state. +function modelOf(graph, lookup = (vars) => vars) { + const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); + for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); + const index = new Map( + [...graph.nodes.values()].map((n) => [stateKey(n.raw ?? n.vars), byId.get(n.id)]), + ); + const facts = (vars) => factsFrom(index.get(stateKey(lookup(vars))) ?? new Set()); + return { + graph, + facts, + key: (vars) => keyOf(vars, facts(vars)), + customer: (vars) => String(mapping.state(vars, facts(vars))), + }; +} + +function quotient(m) { + const { graph } = m; + const keys = new Map(); + for (const n of graph.nodes.values()) keys.set(n.id, m.key(n.vars)); + const nodes = new Set(keys.values()); + const initial = new Set( + [...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id)), + ); + const edges = new Map(); + for (const e of graph.edges) { + const from = keys.get(e.from); + const to = keys.get(e.to); + if (from === to) continue; + const id = `${from}${SEP}${SEP}${to}`; + if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() }); + const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars]; + const label = stepLabel(e.action, before, after); + if (!label) continue; + edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); + addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); + } + // Most common wording first. A chart edge shows two unless --all-labels. + for (const e of edges.values()) + e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); + const settled = new Set( + [...graph.nodes.values()].filter((n) => m.facts(n.vars).settled).map((n) => keys.get(n.id)), + ); + return { nodes, initial, edges, keys, settled }; +} + +const graph = parseDot(fs.readFileSync(args.dot, "utf8")); +const main = modelOf(graph); +const now = quotient(main); +const mainInit = [...graph.nodes.values()].find((n) => n.init).vars; + +const secondDot = args.fixedDot ?? args.againstDot; +const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null; +let lacks = []; +let extra = []; +let second = null; +let secondQ = null; +if (secondGraph) { + const secondInit = [...secondGraph.nodes.values()].find((n) => n.init).vars; + lacks = lacking(mainInit, secondInit); + extra = lacking(secondInit, mainInit); + for (const n of secondGraph.nodes.values()) { + n.raw = n.vars; + n.vars = fill(n.raw, lacks, mainInit); + } + second = modelOf(secondGraph, (vars) => omit(vars, lacks)); + secondQ = quotient(second); +} // Reader path of one trace: drop system steps that keep the reader in the same state. // Reader actions stay even then, because the reader did them. -function readerPath(trace, key0 = keyOf) { - const out = [{ key: key0(trace.steps[0].vars), label: null, modelStep: 0 }]; - for (let i = 1; i < trace.steps.length; i++) { - const key = key0(trace.steps[i].vars); - if (key === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; - const label = stepLabel(trace.steps[i].action, trace.steps[i - 1].vars, trace.steps[i].vars) || trace.steps[i].action; - out.push({ key, label, modelStep: i }); - } - return out; -} - -// Replay a trace's actions in the fixed graph. Returns where the fixed model leaves the trace. -function replayFixed(trace) { - if (!fixedGraph) return null; - const byKey = new Map([...fixedGraph.nodes.values()].map((n) => [stateKey(n.vars), n])); - const out = new Map(); - for (const e of [...fixedGraph.edges, ...fixedGraph.loops]) { - if (!out.has(e.from)) out.set(e.from, []); - out.get(e.from).push(e); - } - let cur = byKey.get(stateKey(trace.steps[0].vars)); - if (!cur) return { step: 0, reason: "the fixed model has no matching initial state" }; - for (let i = 1; i < trace.steps.length; i++) { - const want = trace.steps[i]; - const same = (out.get(cur.id) ?? []).filter((e) => e.action === want.action); - const exact = same.find((e) => stateKey(fixedGraph.nodes.get(e.to).vars) === stateKey(want.vars)); - if (exact) { - cur = fixedGraph.nodes.get(exact.to); - continue; - } - const score = (vars) => Object.keys(want.vars).filter((k) => JSON.stringify(vars[k]) === JSON.stringify(want.vars[k])).length; - const best = same.map((e) => fixedGraph.nodes.get(e.to)).sort((a, b) => score(b.vars) - score(a.vars))[0]; - const reached = best ?? cur; - useFacts(fixedIndex); - const key = keyOf(reached.vars); - useFacts(graphIndex); - return { step: i, action: want.action, disabled: !best, key }; - } - return { step: trace.steps.length, sameEnd: true }; -} - -function readTraces(files) { - return files.flatMap((f) => { - const text = fs.readFileSync(f, "utf8"); - checkTlcOutput(text, f); - return parseTraces(text); - }); +function readerPath(trace, key) { + const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; + for (let i = 1; i < trace.steps.length; i++) { + const k = key(trace.steps[i].vars); + if (k === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const label = stepLabel(trace.steps[i].action, before, after) || trace.steps[i].action; + const guard = stepGuard(trace.steps[i].action, before, after); + out.push({ key: k, label, guard, modelStep: i }); + } + return out; +} + +// Replay a counterexample on the second model, comparing only the variables both models have. +// Returns where the second model leaves the counterexample. +function replaySecond(trace) { + if (!second) return null; + const shared = (vars) => stateKey(omit(vars, lacks)); + const sharedOf = (n) => stateKey(omit(n.raw, extra)); + const out = new Map(); + for (const e of [...secondGraph.edges, ...secondGraph.loops]) { + if (!out.has(e.from)) out.set(e.from, []); + out.get(e.from).push(e); + } + const actions = new Set([...secondGraph.edges, ...secondGraph.loops].map((e) => e.action)); + const nodeOf = (id) => secondGraph.nodes.get(id); + let cur = [...secondGraph.nodes.values()].find( + (n) => n.init && sharedOf(n) === shared(trace.steps[0].vars), + ); + if (!cur) return { step: 0, reason: "starts somewhere else" }; + for (let i = 1; i < trace.steps.length; i++) { + const want = trace.steps[i]; + const target = shared(want.vars); + const from = out.get(cur.id) ?? []; + const exact = from.find((e) => e.action === want.action && sharedOf(nodeOf(e.to)) === target); + if (exact) { + cur = nodeOf(exact.to); + continue; + } + if (!actions.has(want.action)) { + if (target === sharedOf(cur)) continue; + const any = from.find((e) => sharedOf(nodeOf(e.to)) === target); + if (any) { + cur = nodeOf(any.to); + continue; + } + } + const wanted = omit(want.vars, lacks); + const score = (n) => { + const have = omit(n.raw, extra); + return Object.keys(wanted).filter((k) => stateKey(have[k]) === stateKey(wanted[k])).length; + }; + const best = from + .filter((e) => e.action === want.action) + .map((e) => nodeOf(e.to)) + .sort((a, b) => score(b) - score(a))[0]; + const before = trace.steps[i - 1].vars; + const vars = fill((best ?? cur).raw, lacks, before); + return { step: i, action: want.action, disabled: !best, key: second.key(vars), vars, before }; + } + return { step: trace.steps.length, sameEnd: true }; +} + +function readTraces(files, paths = []) { + return files.flatMap((f) => { + const text = fs.readFileSync(f, "utf8"); + checkTlcOutput(text, f); + return parseTraces(text).map((t) => ({ + ...t, + steps: t.steps.map((s) => ({ ...s, vars: fill(s.vars, paths, mainInit) })), + })); + }); } // With -continue, TLC reports every bad state, including states after the first bad one. // Cut each trace at its first bad state so one bug counts once. function firstBad(raw) { - const badStates = new Map(); - for (const t of raw) { - if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); - badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); - } - const cuts = []; - const seen = new Set(); - for (const t of raw) { - const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); - const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; - const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; - if (seen.has(id)) continue; - seen.add(id); - cuts.push(cut); - } - return cuts; + const badStates = new Map(); + for (const t of raw) { + if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); + badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); + } + const cuts = []; + const seen = new Set(); + for (const t of raw) { + const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); + const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; + const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; + if (seen.has(id)) continue; + seen.add(id); + cuts.push(cut); + } + return cuts; } const rawTraces = readTraces(args.traces); -const rawFixedTraces = readTraces(args.fixedTraces); +const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; +const rawSecondTraces = readTraces(secondTraceFiles, lacks); const traces = firstBad(rawTraces); -const fixedTraces = firstBad(rawFixedTraces); +const secondTraces = firstBad(rawSecondTraces); // Classes and their shortest traces come from the customer states in every view, // so class N is the same bug and the same trace in the customer and developer charts. -function group(cuts) { - const byId = new Map(); - for (const trace of cuts) { - const rp = readerPath(trace, customerKey); - const last = trace.steps[trace.steps.length - 1].vars; - const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; - const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; - const prev = byId.get(id); - if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { - byId.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); - } else prev.count++; - } - return byId; -} -const byOrder = (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); -const classesById = group(traces); +function group(cuts, m) { + const byId = new Map(); + for (const trace of cuts) { + const rp = readerPath(trace, m.customer); + const last = trace.steps[trace.steps.length - 1].vars; + const cause = + typeof mapping.cause === "function" + ? String(mapping.cause(last, m.facts(last), trace.invariant)) + : null; + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = byId.get(id); + if ( + !prev || + rp.length < prev.steps || + (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length) + ) { + byId.set(id, { + id, + cause, + steps: rp.length, + trace, + invariant: trace.invariant, + count: (prev?.count ?? 0) + 1, + }); + } else prev.count++; + } + return byId; +} +// Fewest reader steps first, then fewest model steps, then rule name, then cause (or end state). +const byOrder = (a, b) => + a.steps - b.steps || + a.trace.steps.length - b.trace.steps.length || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +const classesById = group(traces, main); const classes = [...classesById.values()].sort(byOrder); for (const c of classes) { - c.path = readerPath(c.trace); - c.end = c.path[c.path.length - 1].key; - c.fix = replayFixed(c.trace); + c.path = readerPath(c.trace, main.key); + c.end = c.path[c.path.length - 1].key; + c.second = replaySecond(c.trace); } -// A bug of the fixed model that no current bug matches is new with the fix. It numbers after the current bugs. -useFacts(fixedIndex ?? graphIndex); -const introduced = [...group(fixedTraces).values()].filter((c) => !classesById.has(c.id)).sort(byOrder); +// A bug of the second model that no current bug matches numbers after the current bugs. +const introduced = second + ? [...group(secondTraces, second).values()].filter((c) => !classesById.has(c.id)).sort(byOrder) + : []; for (const c of introduced) { - c.path = readerPath(c.trace); - c.end = c.path[c.path.length - 1].key; - c.fix = null; - c.introduced = true; + c.path = readerPath(c.trace, second.key); + c.end = c.path[c.path.length - 1].key; + c.second = null; + c.introduced = true; } -useFacts(graphIndex); classes.push(...introduced); // ---------- status ---------- // A chart state is bad when it holds a state that breaks a rule, or when every path from the start to -// it passes such a state: what the reader sees after a bug is part of that bug. It is still wrong when -// it is bad in the fixed model, and fixed when it is bad only in the current model. -// Without --fixed-traces every bad state is wrong. -const isFixed = (c) => c.fix?.key !== undefined && c.fix.key !== c.end; -function badKeys(g, keys, raw) { - const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); - const out = new Set(raw.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); - if (!g) return out; - const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); - const next = new Map(); - for (const e of g.edges) { - if (!next.has(e.from)) next.set(e.from, []); - next.get(e.from).push(e.to); - } - const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); - const queue = [...clean]; - while (queue.length) { - for (const to of next.get(queue.shift()) ?? []) { - if (clean.has(to) || !ok(to)) continue; - clean.add(to); - queue.push(to); - } - } - const reached = new Set([...clean].map((id) => keys.get(id))); - for (const k of keys.values()) if (!reached.has(k)) out.add(k); - return out; -} -const badNow = badKeys(graph, now.keys, rawTraces); -useFacts(fixedIndex ?? graphIndex); -const badFixed = args.fixedTraces.length > 0 ? badKeys(fixedGraph, fixed?.keys, rawFixedTraces) : badNow; -useFacts(graphIndex); -const allNodes = new Set([...now.nodes, ...(fixed?.nodes ?? [])]); -const status = (k) => (badFixed.has(k) ? "wrong" : badNow.has(k) || (fixed && !now.nodes.has(k)) ? "fixed" : "works"); +// it passes such a state: what the reader sees after a bug is part of that bug. +// With a fix, a state is still wrong when it is bad in the fixed model, and fixed when it is bad only +// in the current model; without --fixed-traces every bad state is wrong. +// Against a spec, a state is wrong when it is bad in either model, and green when only the spec has it. +const diverges = (c) => c.second?.key !== undefined && c.second.key !== c.end; +function badKeys(m, keys, raw) { + const g = m.graph; + const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); + const out = new Set(raw.map((t) => m.key(t.steps[t.steps.length - 1].vars))); + const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); + const next = new Map(); + for (const e of g.edges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); + const queue = [...clean]; + while (queue.length) { + for (const to of next.get(queue.shift()) ?? []) { + if (clean.has(to) || !ok(to)) continue; + clean.add(to); + queue.push(to); + } + } + const reached = new Set([...clean].map((id) => keys.get(id))); + for (const k of keys.values()) if (!reached.has(k)) out.add(k); + return out; +} +const badNow = badKeys(main, now.keys, rawTraces); +const badSecond = + second && secondTraceFiles.length > 0 + ? badKeys(second, secondQ.keys, rawSecondTraces) + : role === "fix" + ? badNow + : new Set(); +const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); +for (const c of classes) if (diverges(c)) allNodes.add(c.second.key); +const onlySecond = (k) => second && !now.nodes.has(k); +const status = (k) => { + if (role === "against") + return badNow.has(k) || badSecond.has(k) ? "wrong" : onlySecond(k) ? "fixed" : "works"; + if (role === "fix") + return badSecond.has(k) ? "wrong" : badNow.has(k) || onlySecond(k) ? "fixed" : "works"; + return badNow.has(k) ? "wrong" : "works"; +}; const edges = new Map(now.edges); -if (fixed) { - for (const [id, e] of fixed.edges) { - if (!edges.has(id)) edges.set(id, { ...e, labels: new Set([...e.labels].map((l) => `${l} (with the fix)`)), fixedOnly: true }); - } -} -// The fix changes one model step of the counterexample. Its edge leaves the chart state before that -// step and ends where the fixed model goes instead, or stays put when the fix blocks the step. -const fixEdgeId = (c) => `${c.fix.from}${SEP}${SEP}${c.fix.key}${SEP}fix`; +if (secondQ) { + const only = (l) => `${l} (${within} ${L})`; + for (const [id, e] of secondQ.edges) { + if (!edges.has(id)) + edges.set(id, { + ...e, + labels: new Set([...e.labels].map(only)), + guards: new Map([...e.guards].map(([l, words]) => [only(l), words])), + secondOnly: true, + }); + } +} +// The second model changes one model step of the counterexample. Its edge leaves the chart state +// before that step and ends where the second model goes instead, or stays put when the second model +// cannot take the step. +const secondEdgeId = (c) => `${c.second.from}${SEP}${SEP}${c.second.key}${SEP}second`; for (const c of classes) { - if (!isFixed(c)) continue; - const [before, after] = [c.trace.steps[c.fix.step - 1].vars, c.trace.steps[c.fix.step].vars]; - c.fix.from = keyOf(before); - const said = stepLabel(c.fix.action, before, after) || c.fix.action; - const id = fixEdgeId(c); - if (!edges.has(id)) edges.set(id, { from: c.fix.from, to: c.fix.key, labels: new Set(), fixedOnly: true, fixOf: new Set() }); - edges.get(id).labels.add(c.fix.disabled ? `${said}, blocked by the fix` : `${said}, with the fix`); - edges.get(id).fixOf.add(c); + if (!diverges(c)) continue; + const { action, before } = c.second; + const after = c.trace.steps[c.second.step].vars; + c.second.from = main.key(before); + c.second.said = stepLabel(action, before, after) || action; + c.second.said2 = stepLabel(action, before, c.second.vars) || c.second.said; + const id = secondEdgeId(c); + if (!edges.has(id)) + edges.set(id, { + from: c.second.from, + to: c.second.key, + labels: new Set(), + secondOnly: true, + secondOf: new Set(), + }); + const blocked = role === "fix" ? `blocked by ${L}` : `not in ${L}`; + edges + .get(id) + .labels.add( + c.second.disabled ? `${c.second.said}, ${blocked}` : `${c.second.said2}, ${within} ${L}`, + ); + edges.get(id).secondOf.add(c); } // ---------- highlighted path ---------- const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; -if (args.highlight > 0 && classes.length > 0 && !chosen) throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); +if (args.highlight > 0 && classes.length > 0 && !chosen) + throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); const pathNumbers = new Map(); if (chosen) { - chosen.path.slice(1).forEach((p, i) => { - const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; - if (!pathNumbers.has(id)) pathNumbers.set(id, { n: [], label: p.label }); - pathNumbers.get(id).n.push(i + 1); - if (!edges.has(id)) edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); - }); - if (isFixed(chosen)) onPath.add(chosen.fix.key); + chosen.path.slice(1).forEach((p, i) => { + const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; + if (!pathNumbers.has(id)) + pathNumbers.set(id, { + n: [], + label: p.label, + shown: p.guard ? `${p.label} [${p.guard}]` : p.label, + }); + pathNumbers.get(id).n.push(i + 1); + if (!edges.has(id)) + edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); + }); + if (diverges(chosen)) onPath.add(chosen.second.key); } // ---------- cover: shortest path to each settled reader state ---------- let coverEdges = null; if (args.cover) { - const keepEdges = new Set(); - const bfs = (q, targets) => { - const parent = new Map([...q.initial].map((k) => [k, null])); - const queue = [...q.initial]; - const outEdges = new Map(); - for (const [id, e] of q.edges) { - if (!outEdges.has(e.from)) outEdges.set(e.from, []); - outEdges.get(e.from).push([id, e]); - } - while (queue.length) { - const k = queue.shift(); - for (const [id, e] of outEdges.get(k) ?? []) { - if (parent.has(e.to)) continue; - parent.set(e.to, id); - queue.push(e.to); - } - } - for (const t of targets) { - for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); - } - }; - bfs(now, now.settled); - if (fixed) bfs(fixed, [...fixed.settled].filter((k) => !now.nodes.has(k))); - for (const c of classes) { - c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); - if (isFixed(c)) keepEdges.add(fixEdgeId(c)); - } - for (const id of keepEdges) { - if (!edges.has(id)) { - const [from, to] = id.split(`${SEP}${SEP}`); - const c = classes.find((x) => x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to)); - const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); - edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); - } - } - coverEdges = keepEdges; + const keepEdges = new Set(); + const bfs = (q, targets) => { + const parent = new Map([...q.initial].map((k) => [k, null])); + const queue = [...q.initial]; + const outEdges = new Map(); + for (const [id, e] of q.edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from).push([id, e]); + } + while (queue.length) { + const k = queue.shift(); + for (const [id, e] of outEdges.get(k) ?? []) { + if (parent.has(e.to)) continue; + parent.set(e.to, id); + queue.push(e.to); + } + } + for (const t of targets) { + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); + } + }; + bfs(now, now.settled); + if (secondQ) + bfs( + secondQ, + [...secondQ.settled].filter((k) => !now.nodes.has(k)), + ); + for (const c of classes) { + c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); + if (diverges(c)) keepEdges.add(secondEdgeId(c)); + } + for (const id of keepEdges) { + if (!edges.has(id)) { + const [from, to] = id.split(`${SEP}${SEP}`); + const c = classes.find((x) => + x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to), + ); + const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); + edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); + } + } + coverEdges = keepEdges; } // ---------- Mermaid ---------- +const LEGEND = { + works: ["legendWorks", role === "fix" ? "Works today" : "Works"], + wrong: ["legendWrong", role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"], + fixed: ["legendFixed", role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`], +}; + +// ---------- groups ---------- +// The mapping's group() names the group of each model state, and a chart state takes the group +// that all its model states share. The fill shows the group. The status from TLC stays a border on +// top, so no group hides a broken rule or the second model's states. +const grouping = typeof mapping.group === "function"; +const groupsOf = new Map(); +const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : ""); +let groupNames = []; +const fills = new Map(); +if (grouping) { + const add = (k, vars, m) => { + const g = mapping.group(vars, m.facts(vars)); + if (g !== undefined && g !== null && typeof g !== "string") + throw new Error(`group() must return a group name or nothing; got ${JSON.stringify(g)}`); + if (!groupsOf.has(k)) groupsOf.set(k, new Set()); + groupsOf.get(k).add((g ?? "").replace(/\s+/g, " ").trim()); + }; + for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); + if (second) + for (const n of secondGraph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); + for (const c of classes) if (diverges(c)) add(c.second.key, c.second.vars, second); + + const given = mapping.groupColors ?? {}; + if (!isRecord(given)) + throw new Error("groupColors must be an object from a group name to a color such as #d6eaf8"); + const statusNames = new Set( + [ + "Works", + "Works today", + "Breaks a rule", + "Still wrong", + "Fixed", + ...Object.values(LEGEND).map(([, t]) => t), + ].map((t) => t.toLowerCase()), + ); + const found = new Set([...groupsOf.values()].flatMap((gs) => [...gs]).filter(Boolean)); + for (const g of found) + if (statusNames.has(g.toLowerCase())) + throw new Error( + `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`, + ); + groupNames = [ + ...Object.keys(given).filter((g) => found.has(g)), + ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(), + ]; + for (const [g, color] of Object.entries(given)) { + if (color === undefined || color === null || color === "") continue; + const why = groupColorProblem(color); + if (why) throw new Error(`groupColors["${g}"] ${JSON.stringify(color)} ${why}`); + if (found.has(g)) fills.set(g, color.toLowerCase()); + } + const free = GROUP_PALETTE.filter((c) => ![...fills.values()].includes(c)); + for (const g of groupNames) { + if (fills.has(g)) continue; + if (!free.length) + throw new Error( + `the mapping has ${groupNames.length} groups, more than the ${GROUP_PALETTE.length} colors of the palette; give the others a color in groupColors`, + ); + fills.set(g, free.shift()); + } +} +const mixedGroups = [...groupsOf].filter(([, gs]) => gs.size > 1).map(([k]) => k); + +const BORDER = { works: "", wrong: "Wrong", fixed: "Fixed" }; +const classFor = (g, s, path) => + grouping + ? `${g ? `group${groupNames.indexOf(g)}` : "plain"}${BORDER[s] || (path ? "Path" : "")}` + : `${s}${path ? "Path" : ""}`; +const drawnState = (g, s, path = false) => ({ group: g, status: s, cls: classFor(g, s, path) }); +const groupCounts = (drawn) => + groupNames + .map((g) => ({ + name: g, + color: fills.get(g), + states: drawn.filter((d) => d.group === g).length, + })) + .filter((g) => g.states > 0); + +// drawn holds { group, status, cls } for each state the chart draws. +function head(drawn) { + const out = []; + if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); + out.push("stateDiagram-v2"); + if (role) out.push(` %% second model (${role}): ${L}`); + const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s)); + const legend = grouping + ? [ + ...groupCounts(drawn).map(({ name }) => { + const i = groupNames.indexOf(name); + return [`legendGroup${i}`, name, `group${i}`]; + }), + ...used.filter((s) => BORDER[s]).map((s) => [...LEGEND[s], `plain${BORDER[s]}`]), + ] + : used.map((s) => [...LEGEND[s], s]); + if (grouping) { + const classes = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); + const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])]; + for (const [base, fill] of bases) + for (const border of ["", "Wrong", "Fixed", "Path"]) + if (classes.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); + } else for (const c of STATUS_CLASSES) out.push(` ${c}`); + if (legend.length) { + out.push(` state "Legend" as ${LEGEND_ID} {`); + for (const [id, text] of legend) out.push(` state "${quote(text)}" as ${id}`); + out.push(" }"); + for (const [id, , c] of legend) out.push(` class ${id} ${c}`); + } + return out; +} + +// ---------- parallel regions ---------- +// Splits each "context: progress" label into a context region and a progress region. A step of +// one region that happens beside only some states of the other gets a guard. The chart is used +// only when it has fewer states than the flat chart, and when, read back, its combined machine +// has exactly the flat chart's states, start states, and labeled steps. +function regionsChart() { + const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim(); + const split = (k) => { + const at = k.indexOf(": "); + return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null; + }; + const nodes = [...allNodes].sort(); + if (!nodes.every(split)) return null; + const starts = [...new Set([...now.initial, ...(secondQ?.initial ?? [])])]; + const steps = []; + for (const e of edges.values()) { + if (e.labels.size === 0) return { why: "a step has no label" }; + for (const l of e.labels) + steps.push({ + from: split(e.from), + to: split(e.to), + label: norm(l), + words: e.guards?.get(l) ?? new Set([""]), + }); + } + const bar = (p) => p.join(" | "); + const flat = { + Main: { + states: new Set(nodes.map((k) => bar(split(k)))), + initial: new Set(starts.map((k) => bar(split(k)))), + transitions: steps.map((s) => ({ from: bar(s.from), to: bar(s.to), label: s.label })), + }, + }; + const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`])); + const proof = [ + "stateDiagram-v2", + ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), + ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), + ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), + ]; + if (args.proof) fs.writeFileSync(args.proof, proof.join("\n") + "\n"); + + // Each region state, and the states of the other region that it is part of a state with. + const beside = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + if (!beside[i].has(p[i])) beside[i].set(p[i], new Set()); + beside[i].get(p[i]).add(p[1 - i]); + } + } + const size = beside[0].size + beside[1].size; + if (size >= nodes.length) + return { why: `${size} region states are not fewer than the ${nodes.length} flat states` }; + + // A region state's status is the lowest status of the states it is part of, and each state must + // then show the higher status of its two region states. + const rank = ["works", "fixed", "wrong"]; + const regionStatus = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionStatus[i].get(p[i]); + if (!prev || rank.indexOf(status(k)) < rank.indexOf(prev)) + regionStatus[i].set(p[i], status(k)); + } + } + for (const k of nodes) { + const p = split(k); + const shown = rank[Math.max(...[0, 1].map((i) => rank.indexOf(regionStatus[i].get(p[i]))))]; + if (shown !== status(k)) + return { + why: `"${k}" is "${LEGEND[status(k)][1]}", and its two region states would show "${LEGEND[shown][1]}"`, + }; + } + + // A region takes every step it has, so a step that leaves a region state in some states and keeps + // it in others needs a loop there: the loop says the region may also stay. + const moves = [new Map(), new Map()]; + for (const s of steps) + for (const i of [0, 1]) { + const id = [s.from[i], s.label, s.to[i]].join(SEP); + if (!moves[i].has(id)) + moves[i].set(id, { + from: s.from[i], + label: s.label, + to: s.to[i], + beside: new Set(), + guards: new Map(), + }); + moves[i].get(id).beside.add(s.from[1 - i]); + for (const w of s.words) addGuard(moves[i].get(id).guards, s.label, norm(w)); + } + for (const m of moves) + for (const [id, x] of m) + if ( + x.from === x.to && + ![...m.values()].some((y) => y.from === x.from && y.label === x.label && y.to !== x.from) + ) + m.delete(id); + const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`]))); + // A region state is in a group when every state it is part of is in that group. + const regionGroup = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionGroup[i].get(p[i]); + regionGroup[i].set(p[i], prev === undefined || prev === groupOf(k) ? groupOf(k) : ""); + } + } + const drawn = rid.map((m, i) => + [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))), + ); + // keep holds the ids of the arrows to draw in each region, or is null for every arrow. + const draw = (keep) => { + const out = head(drawn.flat()); + out.push(` state "At the same time" as regions {`); + let count = 0; + for (const i of [0, 1]) { + if (i) out.push(" --"); + const name = mapping.regionNames?.[i]; + const pad = name ? " " : " "; + if (name) out.push(` state "${quote(wrap(name))}" as region${i} {`); + for (const s of new Set(starts.map((k) => split(k)[i]))) + out.push(`${pad}[*] --> ${rid[i].get(s)}`); + for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); + const sorted = [...moves[i]] + .filter(([id]) => !keep || keep[i].has(id)) + .map(([, m]) => m) + .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label)); + for (const m of sorted) { + // The guard names the shorter of the states the step happens beside, or those it does not. + // The mapping's guard words, when it has them, replace those names in the label. + const all = beside[i].get(m.from); + const not = [...all].filter((s) => !m.beside.has(s)).sort(); + const yes = [...m.beside].sort(); + const unless = not.length > 0 && not.length < yes.length; + const guard = not.length === 0 ? null : unless ? not : yes; + const worded = guarded(m.guards, m.label); + const text = + worded !== m.label || !guard + ? worded + : `${m.label} [${unless ? "unless" : "when"} ${guard.join(" or ")}]`; + out.push(`${pad}${rid[i].get(m.from)} --> ${rid[i].get(m.to)} : ${wrap(text, 30)}`); + if (guard) + out.push( + `${pad}%% when ${unless ? "not " : ""}${guard.map((s) => rid[1 - i].get(s)).join(", ")}`, + ); + count++; + } + if (name) out.push(" }"); + } + out.push(" }"); + for (const i of [0, 1]) + [...rid[i].values()].forEach((id, j) => out.push(` class ${id} ${drawn[i][j].cls}`)); + return { mermaid: out.join("\n") + "\n", count }; + }; + + const all = draw(null); + const differences = compareCharts(parseMermaid(all.mermaid).regions, flat); + if (differences.length) { + const n = differences.reduce((sum, [, xs]) => sum + xs.length, 0); + return { + why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`, + }; + } + if (args.regionsAll) fs.writeFileSync(args.regionsAll, all.mermaid); + + // The overview keeps each bug path, then a way from the start to each region state and one step + // with each label, as the arrows of the regions that each step moves. It adds the cheapest of + // those next: the fewest new arrows, then the fewest steps. A region with fewer arrows can stay + // where the full chart moves it, so each step that the kept arrows make and TLC's graph does + // not have gets back every arrow that the full chart takes there. + const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)])); + const key = (from, label, to) => `${from} --> ${to} : ${label}`; + const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to)))); + const next = new Map(); + for (const s of steps) { + if (!next.has(bar(s.from))) next.set(bar(s.from), []); + next.get(bar(s.from)).push(s); + } + const keep = [new Set(), new Set()]; + const movesOf = (s) => + [0, 1] + .filter((i) => s.from[i] !== s.to[i]) + .map((i) => [i, [s.from[i], s.label, s.to[i]].join(SEP)]); + const price = (s) => movesOf(s).filter(([i, id]) => !keep[i].has(id)).length * nodes.length + 1; + const wanted = []; + const want = (s) => { + wanted.push(s); + for (const [i, id] of movesOf(s)) keep[i].add(id); + }; + const stepsOf = (e, said) => { + const labels = [...e.labels].map(norm); + return (labels.includes(said) ? [said] : labels).map((label) => ({ + from: split(e.from), + to: split(e.to), + label, + })); + }; + for (const c of classes) { + c.path.slice(1).forEach((p, j) => { + const e = edges.get(`${c.path[j].key}${SEP}${SEP}${p.key}`); + if (e) stepsOf(e, norm(p.label)).forEach(want); + }); + if (diverges(c)) stepsOf(edges.get(secondEdgeId(c)), "").forEach(want); + } + const cheapest = () => { + const cost = new Map(starts.map((k) => [bar(split(k)), 0])); + const parent = new Map(); + const open = new Set(cost.keys()); + while (open.size) { + const k = [...open].reduce((a, b) => (cost.get(b) < cost.get(a) ? b : a)); + open.delete(k); + for (const s of next.get(k) ?? []) { + const c = cost.get(k) + price(s); + if (c < (cost.get(bar(s.to)) ?? Infinity)) { + cost.set(bar(s.to), c); + parent.set(bar(s.to), s); + open.add(bar(s.to)); + } + } + } + return { cost, parent }; + }; + const targets = [ + ...[0, 1].flatMap((i) => + [...beside[i].keys()].sort().map((r) => ({ at: (k) => parts.get(k)[i] === r })), + ), + ...[...new Set(steps.map((s) => s.label))] + .filter((l) => !wanted.some((s) => s.label === l)) + .sort() + .map((l) => ({ ends: (s) => s.label === l })), + ]; + while (targets.length) { + const { cost, parent } = cheapest(); + let best = null; + targets.forEach((t, n) => { + const ways = t.at + ? [...cost.keys()].filter(t.at).map((k) => ({ k, c: cost.get(k), last: null })) + : steps + .filter(t.ends) + .filter((s) => cost.has(bar(s.from))) + .map((s) => ({ k: bar(s.from), c: cost.get(bar(s.from)) + price(s), last: s })); + for (const w of ways) if (!best || w.c < best.c) best = { ...w, n }; + }); + if (!best) break; + targets.splice(best.n, 1); + const path = best.last ? [best.last] : []; + for (let s = parent.get(best.k); s; s = parent.get(bar(s.from))) path.push(s); + path.forEach(want); + } + const takes = (x, label) => + [0, 1].flatMap((i) => + [...moves[i]] + .filter(([, m]) => m.from === x[i] && m.label === label && m.beside.has(x[1 - i])) + .map(([id]) => [i, id]), + ); + let scoped; + let made; + for (;;) { + scoped = draw(keep); + made = productOf(parseMermaid(scoped.mermaid).regions); + const drawn = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); + const redo = [ + ...made.transitions + .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to))) + .map((t) => [parts.get(t.from), t.label]), + ...wanted + .filter((s) => made.states.has(bar(s.from))) + .filter((s) => !drawn.has(key(bar(s.from), s.label, bar(s.to)))) + .map((s) => [s.from, s.label]), + ]; + const before = keep[0].size + keep[1].size; + for (const [x, label] of redo) for (const [i, id] of takes(x, label)) keep[i].add(id); + if (keep[0].size + keep[1].size === before) break; + } + const sound = + [...made.states].every((s) => parts.has(s)) && + made.transitions.every((t) => real.has(key(t.from, t.label, t.to))); + if (!sound) scoped = all; + + return { + mermaid: scoped.mermaid, + states: [beside[0].size, beside[1].size], + transitions: scoped.count, + all: all.count, + flat: { states: nodes.length, transitions: steps.length }, + status: Object.fromEntries( + rank.map((s) => [ + s, + regionStatus.reduce((n, m) => n + [...m.values()].filter((x) => x === s).length, 0), + ]), + ), + groups: groupCounts(drawn.flat()), + }; +} +const regions = args.regions ? regionsChart() : null; +if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`); + let keep = allNodes; if (args.pathOnly && chosen) keep = onPath; else if (coverEdges) { - keep = new Set([...now.initial, ...(fixed?.initial ?? [])]); - for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); + keep = new Set([...now.initial, ...(secondQ?.initial ?? [])]); + for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); } const ids = new Map(); let counter = 0; const idOf = (k) => { - if (!ids.has(k)) ids.set(k, `s${counter++}`); - return ids.get(k); + if (!ids.has(k)) ids.set(k, `s${counter++}`); + return ids.get(k); }; -const label = (k) => (args.view === "layered" ? k.split(SEP)[1] : k); -const out = []; -if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); -out.push("stateDiagram-v2"); -for (const c of STATUS_CLASSES) out.push(` ${c}`); -out.push(` state "Legend" as ${LEGEND_ID} {`); -out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed or new in an open PR" as legendFixed'); -out.push(" }", " class legendWorks works", " class legendWrong wrong", " class legendFixed fixed"); const sorted = [...keep].sort(); -if (args.view === "layered") { - const groups = new Map(); - for (const k of sorted) { - const outer = k.split(SEP)[0]; - if (!groups.has(outer)) groups.set(outer, []); - groups.get(outer).push(k); - } - let g = 0; - for (const [outer, members] of groups) { - out.push(` state "${quote(wrap(outer))}" as g${g++} {`); - for (const k of members) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); - out.push(" }"); - } -} else { - for (const k of sorted) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); -} -const initial = new Set([...now.initial, ...(fixed?.initial ?? [])]); -for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); +const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k))); + +// Boxes: the states of one outer layer (layered view), or, in the customer view, the states whose +// labels read "context: progress" with the same context. A context with one state stays a plain state. +const boxes = new Map(); +const boxOf = new Map(); +const inBox = (k, box) => { + if (!boxes.has(box)) boxes.set(box, []); + boxes.get(box).push(k); +}; +if (args.view === "layered") for (const k of sorted) inBox(k, k.split(SEP)[0]); +else if (args.view === "customer" && !args.flat) { + for (const k of sorted) { + const at = k.indexOf(": "); + if (at > 0 && at + 2 < k.length) inBox(k, k.slice(0, at)); + } + for (const [box, members] of boxes) if (members.length < 2) boxes.delete(box); +} +const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`])); +for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); +const caption = (k) => + args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; + let edgeCount = 0; +const drawnLabels = new Set(); +const lines = []; for (const [id, e] of edges) { - if (!keep.has(e.from) || !keep.has(e.to)) continue; - if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.fixOf?.has(chosen)) continue; - if (coverEdges && !coverEdges.has(id)) continue; - const all = [...e.labels]; - let text = args.allLabels ? all.join(" or ") : all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); - const p = pathNumbers.get(id); - if (p) text = `${p.n.join(", ")}. ${p.label}`; - // ";" ends a Mermaid statement, even inside a transition label. - text = text.replaceAll(";", ","); - out.push(` ${idOf(e.from)} --> ${idOf(e.to)}${text ? ` : ${wrap(text, 30)}` : ""}`); - edgeCount++; -} -for (const k of sorted) out.push(` class ${idOf(k)} ${status(k)}${onPath.has(k) ? "Path" : ""}`); -const mermaid = out.join("\n") + "\n"; + if (!keep.has(e.from) || !keep.has(e.to)) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.secondOf?.has(chosen)) continue; + if (coverEdges && !coverEdges.has(id)) continue; + const all = [...e.labels]; + const shown = all.map((l) => guarded(e.guards, l)); + let text = args.allLabels + ? shown.join(" or ") + : shown.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + const p = pathNumbers.get(id); + if (p) text = `${p.n.join(", ")}. ${p.shown}`; + for (const l of p ? [p.label] : all) drawnLabels.add(l); + // ";" ends a Mermaid statement, even inside a transition label. + text = text.replaceAll(";", ","); + lines.push({ from: e.from, to: e.to, text, plain: !p && !e.secondOf, labels: [...all].sort() }); + edgeCount++; +} +// One arrow from a box's border stands for the same step from every state in the box. It is drawn +// only when every state in the box has that step, with the same labels, to the same state outside +// the box, so the chart still says exactly what each state does. +const fromBox = []; +for (const [box, members] of boxes) { + const mine = new Set(members); + const byStep = new Map(); + for (const l of lines) { + if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue; + const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`; + if (!byStep.has(step)) byStep.set(step, []); + byStep.get(step).push(l); + } + for (const same of byStep.values()) { + if (new Set(same.map((l) => l.from)).size !== members.length) continue; + for (const l of same) lines.splice(lines.indexOf(l), 1); + fromBox.push({ ...same[0], box }); + } +} + +const out = head(drawn); +for (const [box, members] of boxes) { + out.push(` state "${quote(wrap(box))}" as ${boxIds.get(box)} {`); + for (const k of members) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); + out.push(" }"); +} +for (const k of sorted) + if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); +const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); +for (const k of sorted) + if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) + out.push(` [*] --> ${idOf(k)}`); +const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${wrap(l.text, 30)}` : ""}`; +for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); +for (const l of lines) out.push(arrow(idOf(l.from), l)); +sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); +const mermaid = regions?.mermaid ?? out.join("\n") + "\n"; if (args.out) fs.writeFileSync(args.out, mermaid); else process.stdout.write(mermaid); // ---------- stories and summary ---------- const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); +const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; +// The reader step of a model step, or the last reader step before it when the reader saw no change. +function readerStep(c, step) { + const at = c.path.findIndex((p) => p.modelStep === step); + if (at >= 0) return { at }; + return { after: c.path.findLastIndex((p) => p.modelStep < step) }; +} +function secondLine(c) { + const W = cap(within); + if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`; + const s = c.second; + if (!s) return null; + if (s.sameEnd) return `${W} ${L}, the same steps end in the same place.`; + if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; + const { at, after } = readerStep(c, s.step); + if (s.disabled) + return at !== undefined + ? `${W} ${L}, step ${at} cannot happen, so you stay at: ${show(s.key)}.` + : `${W} ${L}, this path ends after step ${after}, and you stay at: ${show(s.key)}.`; + return at !== undefined + ? `${W} ${L}, step ${at} is "${s.said2}", and you see: ${show(s.key)}.` + : `${W} ${L}, after step ${after}, "${s.said2}" happens, and you see: ${show(s.key)}.`; +} if (args.story) { - const lines = []; - classes.forEach((c, i) => { - lines.push(`## ${i + 1}. ${c.introduced ? "New with the fix: " : ""}${show(c.end)}`, ""); - lines.push(`Breaks \`${c.invariant}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); - c.path.forEach((p, j) => lines.push(j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${p.label}. Now: ${show(p.key)}`)); - lines.push(""); - if (c.introduced) lines.push("Only the fixed model has this bug. The path above is in the fixed model."); - else if (!c.fix) lines.push("No fixed model given."); - else if (c.fix.sameEnd) lines.push("The fixed model follows the whole trace: the fix does not change this path."); - else if (c.fix.reason) lines.push(`The fixed model cannot replay this trace: ${c.fix.reason}.`); - else lines.push(`With the fix, model step ${c.fix.step} (\`${c.fix.action}\`) ${c.fix.disabled ? "cannot happen" : "goes elsewhere"}, and you see: ${show(c.fix.key)}`); - lines.push(""); - }); - fs.writeFileSync(args.story, lines.join("\n")); + const lines = []; + const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `; + classes.forEach((c, i) => { + lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, ""); + lines.push(`Rule broken: ${ruleOf(c)}`); + if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`); + lines.push( + c.count === 1 + ? "This is the only way to get here in the model." + : `There are ${c.count} ways to get here in the model. This is the shortest.`, + "", + ); + c.path.forEach((p, j) => + lines.push( + j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${cap(p.label)}. Now: ${show(p.key)}`, + ), + ); + lines.push(""); + const said = secondLine(c); + if (said) lines.push(said, ""); + }); + fs.writeFileSync(args.story, lines.join("\n")); } +// Labels a product reader would not say: camelCase, snake_case, calls, and joined PascalCase words. +const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/; +const codeLike = [ + ...new Set([ + ...(args.view === "customer" ? [...sorted, ...drawnLabels] : []), + ...classes.map((c) => c.cause).filter(Boolean), + ]), +].filter((t) => CODE_LIKE.test(t)); +const secondOf = (c) => + c.introduced + ? role === "fix" + ? `new with ${L}` + : `only in ${L}` + : c.second + ? c.second.sameEnd + ? "unchanged" + : (c.second.reason ?? show(c.second.key)) + : null; const summary = { - view: args.view, - modelStates: graph.nodes.size, - modelTransitions: graph.edges.length, - chartStates: keep.size, - chartTransitions: edgeCount, - chartStatus: Object.fromEntries(["works", "wrong", "fixed"].map((s) => [s, [...keep].filter((k) => status(k) === s).length])), - classes: classes.map((c, i) => ({ - n: i + 1, - end: show(c.end), - invariant: c.invariant, - cause: c.cause, - readerSteps: c.path.length - 1, - modelSteps: c.trace.steps.length, - counterexamples: c.count, - fix: c.introduced ? "new with the fix" : c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, - })), + view: args.view, + ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), + modelStates: graph.nodes.size, + modelTransitions: graph.edges.length, + chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : keep.size, + chartTransitions: regions?.mermaid ? regions.transitions : edgeCount, + chartStatus: regions?.mermaid + ? regions.status + : Object.fromEntries( + ["works", "wrong", "fixed"].map((s) => [ + s, + [...keep].filter((k) => status(k) === s).length, + ]), + ), + ...(grouping + ? { + chartGroups: regions?.mermaid ? regions.groups : groupCounts(drawn), + mixedGroups: mixedGroups.map(show).sort(), + } + : {}), + ...(regions + ? { + regions: regions.mermaid + ? { + drawn: true, + states: regions.states, + transitions: regions.transitions, + all: regions.all, + flat: regions.flat, + } + : { drawn: false, why: regions.why }, + } + : {}), + classes: classes.map((c, i) => ({ + n: i + 1, + end: show(c.end), + invariant: c.invariant, + rule: rules.get(c.invariant) ?? null, + cause: c.cause, + readerSteps: c.path.length - 1, + modelSteps: c.trace.steps.length, + counterexamples: c.count, + [role ?? "fix"]: secondOf(c), + })), + codeLike, }; if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); +const extraBugs = introduced.length + ? `, ${introduced.length} ${role === "fix" ? `new with ${L}` : `only in ${L}`}` + : ""; +const drawnAs = regions?.mermaid + ? ` of ${regions.all} in 2 regions (proved against ${regions.flat.states} states, ${regions.flat.transitions} transitions)` + : ""; console.error( - `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ` + - `${classes.length - introduced.length} bug(s)${introduced.length ? `, ${introduced.length} new with the fix` : ""}`, + `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` + + `${classes.length - introduced.length} bug(s)${extraBugs}`, ); diff --git a/pstack/README.md b/pstack/README.md index f7cfadd6a..7a8939e2b 100644 --- a/pstack/README.md +++ b/pstack/README.md @@ -151,7 +151,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it | [`/maintain-verification-skill`](./skills/maintain-verification-skill/SKILL.md) | your verify skill's feature map has drifted from the app. source wave + one live pass, at most one PR of proven corrections. | | [`/create-formal-verification-skill`](./skills/create-formal-verification-skill/SKILL.md) | tests can't reach the case that breaks a race, retry, state machine, parser, or unsafe code. verifies one property with the tool that fits and generates a project-local skill to recheck it on every change. | | [`/maintain-formal-verification-skill`](./skills/maintain-formal-verification-skill/SKILL.md) | code your formal verification covers has changed since it was last checked. updates the model, harness, or properties to match the code, reruns, and says whether each failure is drift in the verification or a bug in the code. | -| [`/statechart`](./skills/statechart/SKILL.md) | someone who owns the behavior needs to see it, not read a spec. draws what the user sees as a mermaid statechart generated from a tla+ model, marks each state works, still wrong, or fixed by your open pr, and draws each bug tlc finds as a numbered path. also turns an agreed chart into a tla+ skeleton. | +| [`/statechart`](./skills/statechart/SKILL.md) | someone who owns the behavior needs to see it, not read a spec. draws what the user sees as a mermaid statechart generated from a tla+ model, marks each state works, still wrong, or fixed by your open pr, and draws each bug tlc finds as a numbered path. also turns an agreed chart into a tla+ skeleton, or audits the code against an existing spec. | | [`/unslop`](./skills/unslop/SKILL.md) | you're cleaning up writing. removes AI tells. | | [`/bro`](./skills/bro/SKILL.md) | you want the last message restated in plain human language, no jargon. | | [`/technical-writing`](./skills/technical-writing/SKILL.md) | layered doc standard (Diátaxis + Google developer style + STE + Global English) for docs, RFCs, readmes, PR descriptions, commit messages. | diff --git a/pstack/docs/guide/06-verify-and-ship.md b/pstack/docs/guide/06-verify-and-ship.md index 83735d7e5..c432012d2 100644 --- a/pstack/docs/guide/06-verify-and-ship.md +++ b/pstack/docs/guide/06-verify-and-ship.md @@ -127,6 +127,8 @@ A counterexample in model terms convinces you. The PM or designer who owns the b It also runs the other way. Sketch the behavior as a chart, agree on it with the reader, and the skill writes a TLA+ skeleton from it. TLC checks the agreed chart's rules before any code exists. As guards from the real code go into the spec, each place the code departs from the chart shows up as a difference for the reader to decide. +When the code already has a TLA+ spec, the skill can audit the code against it. The agent models what the code does and checks that model next to the unchanged spec. The chart marks the steps where the two differ, and each rule either one breaks is a numbered path. + ## Open the PR ```text diff --git a/pstack/skills/poteto-help/SKILL.md b/pstack/skills/poteto-help/SKILL.md index a54f117be..bf430fcd5 100644 --- a/pstack/skills/poteto-help/SKILL.md +++ b/pstack/skills/poteto-help/SKILL.md @@ -88,7 +88,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste | Bring a verification skill and its feature map back in line with the app | [`/maintain-verification-skill`](../maintain-verification-skill/SKILL.md) | | Formally verify a race, invariant, or other property that tests rarely reach | [`/create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) | | Bring a formal verification back in line after the code it covers changed, and learn whether a failure is drift or a bug | [`/maintain-formal-verification-skill`](../maintain-formal-verification-skill/SKILL.md) | -| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, or turn an agreed chart into a TLA+ skeleton | [`/statechart`](../statechart/SKILL.md) | +| Show a PM or designer what the user sees as a chart checked by a model, with each bug drawn as a numbered path, turn an agreed chart into a TLA+ skeleton, or audit the code against its TLA+ spec | [`/statechart`](../statechart/SKILL.md) | | Vet a performance number before reporting or acting on it | [`/benchmark-checklist`](../benchmark-checklist/SKILL.md) | | Run a large or cross-cutting change, or one to review after stepping away | [`/figure-it-out`](../figure-it-out/SKILL.md) | | Keep a decision log during a run, and review it afterward | [`/show-me-your-work`](../show-me-your-work/SKILL.md) | diff --git a/pstack/skills/statechart/SKILL.md b/pstack/skills/statechart/SKILL.md index 28ac68cc9..1693be3b3 100644 --- a/pstack/skills/statechart/SKILL.md +++ b/pstack/skills/statechart/SKILL.md @@ -1,126 +1,36 @@ --- name: statechart -description: "Draw what a user sees as a Mermaid statechart generated from a TLA+ model. States are marked works, still wrong, or fixed, and each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." +description: "Draw what an app's user sees as a Mermaid statechart from a TLA+ model. States are gray if they work, red if still wrong, and green if fixed. Each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." disable-model-invocation: true --- # Statechart -A PM, a designer, or a reviewer can judge a chart of what they see. They cannot judge a TLA+ spec. This skill draws the chart from the model, so the chart and the checked model are one object. The reader's rules ("you never see X while Y") become invariants. TLC finds every path that breaks one. The chart shows each as a numbered path in the reader's words. +The chart shows what an app's user sees. A PM reads it to decide whether that behavior is right. Label each state with what is on the screen, such as "Paying". Label each step with what the user does or sees happen, such as "tap Pay" or "the charge goes through". Keep variable and action names out of labels. The scripts call this user the reader, as in `readerActions` and reader steps. -## Pick the direction - -- **A model exists, or the code is concrete enough to model.** Go model to chart. If the project has a formal verification skill, start from its spec. A spec written for one property may not hold what the reader sees. Add those variables and keep its old invariants passing. A new spec follows the "true to the code" rules in [create-formal-verification-skill](../create-formal-verification-skill/SKILL.md). -- **No model, and the reader should agree on the behavior before code exists or changes.** Go chart to model. - -## Setup - -- Java 11 or later and `tla2tools.jar` from the [TLA+ releases](https://github.com/tlaplus/tlaplus/releases). Set `TLA2TOOLS` to the jar's path. -- Node. The scripts in `scripts/` next to this file have no dependencies. `node --test scripts/test/statechart.test.mjs` checks them, and runs the TLC tests too when `TLA2TOOLS` is set. -- To render, `npx -y @mermaid-js/mermaid-cli -i chart.mmd -o chart.png -s 2`. - -Run TLC like this in every step below. `-deadlock` is needed because a UI at rest has nothing left to do, which TLC would report as a deadlock. - -```bash -tlc() { java -cp "$TLA2TOOLS" tlc2.TLC -metadir "$(mktemp -d)" -deadlock -continue "$@"; } -``` - -With `-continue`, TLC exits 0 after a broken rule, and its output still ends with "Model checking completed. No error has been found." So count the `Error: Invariant` lines. A nonzero exit, or no "Model checking completed" line, means the run failed or stopped early. Read the output to find why. `tlc-to-chart.mjs` refuses such a file. - -## Model to chart - -1. **Name the reader's states.** Write what the reader sees and does, in their words. Each name passes one test. The reader can point at it on their screen, or it names a fact they care about. When the harm is off screen, such as a second charge while the screen still says "Paying", put the fact in the name ("Paying, charged twice"). Otherwise the bug's path looks like a normal path. Start coarse. Split a state only when the split separates two bugs. On one model, coarse names found the same bugs as fine names with a third of the states. - -2. **Write the reader's rules as invariants.** One sentence the reader agrees to is one invariant, with the sentence as its comment. For "once everything settles", define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the reader does not take. Never weaken a rule to make the chart pass. A broken rule is the finding. - -3. **Write the mapping module.** It is an ES module that `tlc-to-chart.mjs` imports. - - ```js - // TLA+ actions the reader takes. facts.settled and --cover need them. - export const readerActions = ["EditNote", "OpenApp"]; - // Reader words for one model state. - export function state(v, facts) {} - // Edge label in reader words. - export function step(action, before, after) {} - // Why a bad state is bad, in code terms. - export function cause(v, facts, invariant) {} - // Developer words for one model state. Needed only for --view developer or layered. - export function detail(v, facts) {} - ``` - - - `v`, `before`, and `after` map each variable name to its value. Strings, numbers, and booleans become their JavaScript kind. Sequences, tuples, and sets become arrays, indexed from 0. Records become objects. A function `(a :> 1 @@ b :> 2)` becomes an object keyed by its domain. - - `facts.enabled` is the set of actions the state enables, including steps that change no variable. `facts.settled` is true when every enabled action is a reader action, so nothing else happens until the reader acts. - - `invariant` is the name of the broken rule. - - A step that leaves the reader in the same state is not drawn, whatever `step` returns. A reader action is the exception on a bug path, because the reader did it. When `step` returns `""`, the edge has no label in the overview, and a numbered bug step shows the TLA+ action name. - - Counterexamples that break the same rule with the same `cause` are one bug. Two bugs that end on the same screen through different code paths stay apart only if `cause` names the code path, such as "the badge read the cache before the server answered". +Run `node /scripts/statechart.mjs --help` for the commands and for how to read a chart. Work in a scratch folder outside the repo. The command writes its files next to its input. -4. **Run TLC.** Keep the rules out of `Spec.cfg`. TLC reports only the first rule a state breaks, so each rule gets its own run, with one `INVARIANT` line added to `Spec.cfg`. One more run dumps the state graph. - - ```bash - tlc -dump dot,actionlabels Spec.dot -config Spec.cfg Spec.tla > Spec.out - for rule in TypeOK NoStaleBadge NoLostEdit; do - { cat Spec.cfg; echo; echo "INVARIANT $rule"; } > "Spec-$rule.cfg" - tlc -config "Spec-$rule.cfg" Spec.tla > "Spec-$rule.out" - echo "$rule: exit $?, $(grep -c '^Error: Invariant' "Spec-$rule.out") counterexamples" - done - ``` - - If an open change fixes some of it, model the change as a second spec, `SpecFixed.tla` with `MODULE SpecFixed`. Copy `Spec.cfg` to `SpecFixed.cfg`, and run the same commands with `SpecFixed` in place of `Spec`. - -5. **Draw the reader's chart.** - - ```bash - node scripts/tlc-to-chart.mjs --dot Spec.dot --map map.mjs \ - --traces Spec-NoStaleBadge.out --traces Spec-NoLostEdit.out \ - --fixed-dot SpecFixed.dot \ - --fixed-traces SpecFixed-NoStaleBadge.out --fixed-traces SpecFixed-NoLostEdit.out \ - --cover --highlight 1 --title "Offline editing today" \ - --out chart.mmd --story stories.md --summary summary.json - ``` - - Leave out the `--fixed-*` flags when there is no fix. `--cover` keeps the shortest path to each settled reader state, plus every bug path. Without it, every edge shows, which was four times as many on one model. `--highlight N` numbers the steps of bug N. Bugs are numbered by fewest reader steps, then fewest model steps. Bugs that only the fixed model has come after the current bugs. Run once with `--highlight 0` to get the overview and the numbers in `summary.json`. Then draw one chart per bug for the hand-off. - - Render each chart and open the PNG. Every label is in the reader's words, the legend is there, and the numbered path reads in order. If a label reads like code, fix `state` or `step` and draw again. - -6. **Show one bug's mechanism.** Add `--view developer --path-only --highlight N` to the step 5 command to draw bug N through the `detail` states. Use `--view layered --path-only --highlight N` to put the developer states inside the reader states, for a reviewer who wants both on one page. Always use `--path-only` with these views. Without it, one model gave twelve times the states of the reader's chart. - -7. **Reproduce every red path in the real code.** The model can be wrong. Replay each bug's story from `stories.md` as a test or a live repro against the real code, one step per model action. A bug you cannot reproduce is a modeling error until shown otherwise. Fix the model, not the rule. Report which bugs reproduced. - -## Chart to model - -1. **Write the chart** in Mermaid `stateDiagram-v2`. To make parallel regions, split one top-level composite with `--`. Wrap each part in a named composite, such as `state Connection { ... }`, to name its region and its variable. Each region is one variable. Each transition label is one action. An action can happen when at least one region can take that label. Every region that can take it moves, and the other regions stay where they are. An unlabeled transition is its own action. Write each rule as a comment line. - - ``` - %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. - ``` - - Each name in a `never` line is a state caption, or a composite that stands for every state inside it. The names must come from different regions. Each state needs its own caption. The parser refuses what it cannot model: final states (`X --> [*]`), choice, fork, and history states, regions below the top level, and a `--` outside a composite. Draw "the app closes" as an edge to the state the reader sees next, and "comes back to where it was" as an edge to that state. - -2. **Generate the skeleton.** `node scripts/chart-to-tla.mjs chart.mmd --module Sync` writes `Sync.tla`, `Sync.cfg`, and `Sync.map.mjs`, and prints the rules. `Sync.cfg` names no rule, and a comment in it lists them. Fill in `readerActions` in `Sync.map.mjs` with the actions the reader takes. +## Pick the direction -3. **Check the agreed chart.** Run step 4 of model to chart on `Sync` with the rules it printed. Then draw the chart for comparison and compare. +Use `from-spec` when a TLA+ model exists or the code is concrete enough to model. Use `from-chart` when there is no model and the PM should agree on the behavior before anyone writes or changes the code. - ```bash - node scripts/tlc-to-chart.mjs --dot Sync.dot --map Sync.map.mjs --traces Sync-NeverSyncedWhileOffline.out \ - --highlight 0 --all-labels --out drawn.mmd - node scripts/compare-charts.mjs chart.mmd drawn.mmd - ``` +If the code already has a TLA+ spec, start from a copy of that spec. Add the variables the user sees, and keep the spec's existing invariants. To audit the code against that spec, make the copy model what the code does, and pass the spec unchanged as `--against`. In a new spec, tie every variable and action to code that a reviewer can open, and take its name from that code. Make each action one indivisible step in the code. - Draw the comparison chart with `--highlight 0 --all-labels`, without `--cover` or `--fixed-dot`. `compare-charts.mjs` refuses a chart drawn any other way, or a chart it cannot read, and exits 2. It compares the combined machine of all regions, so a guard that blocks a step in only some combinations of region states shows as a difference. It prints `same` and exits 0 when the states, start states, and transitions match, and exits 1 when they differ. It also lists region states that the agreed chart's own steps never reach. A broken rule here is a design bug in the agreed chart, found before any code. Take it back to the reader. +## Spec to chart -4. **Add the real code.** Add guards and variables from the code to `Sync.tla`, and repeat step 3. Each difference `compare-charts.mjs` prints is a place where the code does not do what the chart says. Give each one to the reader to decide. It is a bug in the code or a correction to the chart. +1. Choose the chart's states. When the PM cares about something the screen does not show, such as a second charge while the screen still says "Paying", name the state "Paying, charged twice". Start with few states, and split a state only when the split separates two bugs. +2. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. For a rule that applies once everything settles, define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the user does not take. Report each broken rule as a bug. Never weaken a rule to make the chart pass. +3. Run `from-spec`, then fill in the mapping file it writes. Put the labels in `state` and `step`, and list the user's actions in `readerActions` by action name. When two bugs end on the same screen through different code paths, add `cause` and make it name the code path. To color the states by what the PM cares about, such as "Toast shown", return that group from `group`. The legend names the groups, and states that break a rule still get a red border. +4. If there is an open fix, model it from the code and pass that model as `--fixed`. +5. Render each chart with `--png` and look at every PNG. Check that each numbered path reads in order. If a label reads like code, fix `state` or `step` and run the command again. +6. Replay each bug's story against the real code, as a test or a live repro, one step per model action. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule. -## Read the chart +## Chart to spec -- Gray states work today. Red states are still wrong with the fix in. Green states are fixed by the open change, or new with it. Without `--fixed-*`, every bad state is red. -- A state is bad when it breaks a rule, or when every path from the start to it passes a state that breaks one. What the reader sees after a bug is part of that bug. -- A thick blue border and numbered labels mark the highlighted bug. Mermaid state diagrams cannot color an edge. -- An edge labeled "with the fix" leaves the state before the step that the fix changes, and ends where the fixed model goes instead. An edge labeled "blocked by the fix" loops on the state where the fix stops that step. An edge labeled "(with the fix)" exists only in the fixed model. -- An edge shows its two most common labels. "or …" means there are more. `--all-labels` shows them all. -- `stories.md` has one entry per bug. It gives the rule, the cause, the shortest path in reader steps, how many counterexamples end there, and what the fixed model does instead. A bug that only the fixed model has is marked "New with the fix". -- `summary.json` has the counts behind the chart. Quote it, not an estimate. Its counterexample counts are lower than the counts from the step 4 loop. TLC reports each bad state, including the bad states after the first one on a path. `summary.json` counts only the paths that end on their first bad state. +1. Draw the chart the PM agrees to. Use one region for each thing that changes independently, such as the connection and the sync badge. Add one `%% never` line per rule, with the PM's sentence. Draw "the app closes" as an edge to the state the user sees next. +2. Run `from-chart`. If a rule breaks, the agreed chart has a design bug. Take the bug to the PM. +3. Add the real code's guards and variables to the spec that `from-chart` wrote, and run `from-chart` again. For each difference it prints, ask the PM whether the code is wrong or the chart needs a correction. ## Hand off -Give the reader the PNG of each chart and its Mermaid source. Add one line per bug with the rule it breaks, the reader's steps, whether it reproduced, and whether the open change fixes it. Attach `stories.md` and the commands that regenerate everything. Say what the model leaves out. +Give the PM the PNG and the Mermaid source of each chart. Add one line per bug with the rule it breaks, its steps as the chart numbers them, whether it reproduced, and whether the open fix fixes it. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out. diff --git a/pstack/skills/statechart/scripts/chart-to-tla.mjs b/pstack/skills/statechart/scripts/chart-to-tla.mjs index d671f48ee..dacbedd94 100644 --- a/pstack/skills/statechart/scripts/chart-to-tla.mjs +++ b/pstack/skills/statechart/scripts/chart-to-tla.mjs @@ -1,35 +1,54 @@ #!/usr/bin/env node -// Write a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. -// -// node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT] -// -// Each region becomes one variable whose values are the region's states. -// Each transition label becomes one action. Regions that share a label move together: -// every region that can take it does, the others stay. An unlabeled transition is its own action. -// Each "%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\ B). -// A name in a never line is a state, or a composite that stands for every state inside it. -// -// Writes NAME.tla, NAME.cfg, and NAME.map.mjs (for tlc-to-chart.mjs) into OUT, next to the chart by default. -// NAME.cfg names no rule; a comment in it lists the rules, and each TLC run appends one INVARIANT line. import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { parseMermaid } from "./lib.mjs"; +const USAGE = "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"; +const HELP = `${USAGE} + +Writes a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. +statechart.mjs from-chart runs it, then checks each INVARIANT in NAME.cfg alone. + +Each region becomes one variable whose values are the region's states. Each transition label +becomes one action. Regions that share a label move together: every region that can take it does, +and the others stay. A "%% when" line after a transition is its guard: the region can take that +transition only while the other regions are in, or with "not" are not in, the states it names. An +unlabeled transition is its own action. Each +"%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\\ B). A name in a +never line is a state, or a composite that stands for every state inside it. + + --module NAME The TLA+ module name. Required. + --dir OUT The folder for the files. Default: the chart's folder. + -h, --help Print this help, and exit 0. + +Writes NAME.tla, NAME.cfg, and NAME.map.mjs into OUT, and replaces files with those names. +NAME.cfg has SPECIFICATION Spec, INVARIANT TypeOK, and one INVARIANT per never line. +NAME.map.mjs gives tlc-to-chart.mjs the reader's words: the region states joined by " | ". + +Exit codes + 0 It wrote the files. + 1 It cannot read the chart. The error says why. + 2 A bad argument.`; +const more = "Run node chart-to-tla.mjs --help for the flags."; + const args = {}; for (let i = 2; i < process.argv.length; i++) { - const a = process.argv[i]; - if (a === "--module") args.module = process.argv[++i]; - else if (a === "--dir") args.dir = process.argv[++i]; - else if (!a.startsWith("--") && !args.chart) args.chart = a; - else { - console.error(`unknown argument ${a}`); - process.exit(2); - } + const a = process.argv[i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--module") args.module = process.argv[++i]; + else if (a === "--dir") args.dir = process.argv[++i]; + else if (!a.startsWith("-") && !args.chart) args.chart = a; + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } } if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) { - console.error("Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"); - process.exit(2); + console.error(`${USAGE}\n${more}`); + process.exit(2); } const dir = args.dir ?? path.dirname(args.chart); const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); @@ -37,42 +56,62 @@ const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]); const taken = new Set(RESERVED); function ident(words, lower) { - const parts = words.normalize("NFKD").replace(/[^A-Za-z0-9]+/g, " ").trim().split(" ").filter(Boolean); - let id = parts.map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)).join("") || "Step"; - if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; - let unique = id; - for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; - taken.add(unique); - return unique; + const parts = words + .normalize("NFKD") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); + let id = + parts + .map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)) + .join("") || "Step"; + if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; + let unique = id; + for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; + taken.add(unique); + return unique; } const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; const set = (xs) => `{${[...xs].map(str).join(", ")}}`; const vars = Object.entries(regions).map(([name, r]) => { - if (r.states.size === 0) throw new Error(`region ${name} has no states`); - if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); - return { name, v: ident(name, true), ...r }; + if (r.states.size === 0) throw new Error(`region ${name} has no states`); + if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); + return { name, v: ident(name, true), ...r }; }); +// A guard reads the other regions in the state before the step. +function guardOf(g) { + if (!g) return null; + const parts = Object.entries(g.states).map(([name, states]) => { + const { v } = vars.find((x) => x.name === name); + return states.length === 1 ? `${v} = ${str(states[0])}` : `${v} \\in ${set(states)}`; + }); + return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ "); +} + // One action per label. Unlabeled transitions get one action each. const actions = []; const byLabel = new Map(); for (const [ri, r] of vars.entries()) { - for (const t of r.transitions) { - let a = t.label ? byLabel.get(t.label) : null; - if (!a) { - a = { label: t.label, moves: new Map() }; - actions.push(a); - if (t.label) byLabel.set(t.label, a); - } - if (!a.moves.has(ri)) a.moves.set(ri, []); - const moves = a.moves.get(ri); - if (!moves.some((m) => m.from === t.from && m.to === t.to)) moves.push({ from: t.from, to: t.to }); - } + for (const t of r.transitions) { + let a = t.label ? byLabel.get(t.label) : null; + if (!a) { + a = { label: t.label, moves: new Map() }; + actions.push(a); + if (t.label) byLabel.set(t.label, a); + } + if (!a.moves.has(ri)) a.moves.set(ri, []); + const moves = a.moves.get(ri); + const when = guardOf(t.guard); + if (!moves.some((m) => m.from === t.from && m.to === t.to && m.when === when)) + moves.push({ from: t.from, to: t.to, when }); + } } for (const a of actions) { - const only = a.label ? null : [...a.moves.values()][0][0]; - a.name = ident(a.label || `${only.from} to ${only.to}`, false); + const only = a.label ? null : [...a.moves.values()][0][0]; + a.name = ident(a.label || `${only.from} to ${only.to}`, false); } const out = []; @@ -80,84 +119,132 @@ const bullet = (indent, op, lines) => lines.map((l, i) => `${" ".repeat(indent)} out.push(`---- MODULE ${args.module} ----`); out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); out.push("\\* Each region is one variable and each transition label is one action."); -out.push("\\* Once you add guards and variables from the real code, this file is the source:"); -out.push("\\* draw the chart from it with tlc-to-chart.mjs and check it with compare-charts.mjs."); +out.push("\\* Once you add guards and variables from the real code, this file is the source."); +out.push( + `\\* statechart.mjs from-chart ${path.basename(args.chart)} keeps it, checks it, and compares it with the chart.`, +); out.push(""); out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`); out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`); out.push(""); -for (const x of vars) out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); +for (const x of vars) + out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); out.push(""); -out.push("TypeOK ==", ...bullet(4, "/\\", vars.map((x) => `${x.v} \\in ${x.v}States`))); +out.push( + "TypeOK ==", + ...bullet( + 4, + "/\\", + vars.map((x) => `${x.v} \\in ${x.v}States`), + ), +); out.push(""); -out.push("Init ==", ...bullet(4, "/\\", vars.map((x) => (x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`)))); +out.push( + "Init ==", + ...bullet( + 4, + "/\\", + vars.map((x) => + x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`, + ), + ), +); for (const a of actions) { - out.push(""); - if (a.label) out.push(`\\* ${a.label}`); - out.push(`${a.name} ==`); - const moving = [...a.moves.keys()]; - const conj = []; - if (moving.length > 1) conj.push(`(${moving.map((ri) => `${vars[ri].v} \\in ${set(new Set(a.moves.get(ri).map((m) => m.from)))}`).join(" \\/ ")})`); - const blocks = []; - for (const ri of moving) { - const v = vars[ri].v; - const ds = a.moves.get(ri).map((m) => `(${v} = ${str(m.from)} /\\ ${v}' = ${str(m.to)})`); - if (moving.length > 1) ds.push(`(${v} \\notin ${set(new Set(a.moves.get(ri).map((m) => m.from)))} /\\ UNCHANGED ${v})`); - blocks.push(ds); - } - const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); - for (const c of conj) out.push(` /\\ ${c}`); - for (const ds of blocks) { - out.push(` /\\ \\/ ${ds[0]}`); - for (const d of ds.slice(1)) out.push(` \\/ ${d}`); - } - if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); + out.push(""); + if (a.label) out.push(`\\* ${a.label}`); + out.push(`${a.name} ==`); + const moving = [...a.moves.keys()]; + // Whether region ri can take the action, or with `not`, cannot. + const can = (ri, not) => { + const v = vars[ri].v; + const moves = a.moves.get(ri); + if (moves.every((m) => !m.when)) + return `${v} ${not ? "\\notin" : "\\in"} ${set(new Set(moves.map((m) => m.from)))}`; + const any = moves + .map((m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""})`) + .join(" \\/ "); + return not ? `~(${any})` : any; + }; + const conj = []; + if (moving.length > 1) conj.push(`(${moving.map((ri) => can(ri, false)).join(" \\/ ")})`); + const blocks = []; + for (const ri of moving) { + const v = vars[ri].v; + const ds = a.moves + .get(ri) + .map( + (m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""} /\\ ${v}' = ${str(m.to)})`, + ); + if (moving.length > 1) ds.push(`(${can(ri, true)} /\\ UNCHANGED ${v})`); + blocks.push(ds); + } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); + for (const c of conj) out.push(` /\\ ${c}`); + for (const ds of blocks) { + out.push(` /\\ \\/ ${ds[0]}`); + for (const d of ds.slice(1)) out.push(` \\/ ${d}`); + } + if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); } out.push(""); -out.push(actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars"); +out.push( + actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars", +); out.push(""); out.push("Spec == Init /\\ [][Next]_vars"); function resolve(item) { - const hits = []; - for (const [ri, x] of vars.entries()) { - const inside = x.states.has(item) ? [item] : [...x.states].filter((s) => s.startsWith(`${item}.`)); - if (inside.length) hits.push({ ri, inside }); - } - if (hits.length === 0) throw new Error(`never line names "${item}", which is not a state in the chart`); - if (hits.length > 1) throw new Error(`never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`); - return hits[0]; + const hits = []; + for (const [ri, x] of vars.entries()) { + const inside = x.states.has(item) + ? [item] + : [...x.states].filter((s) => s.startsWith(`${item}.`)); + if (inside.length) hits.push({ ri, inside }); + } + if (hits.length === 0) + throw new Error(`never line names "${item}", which is not a state in the chart`); + if (hits.length > 1) + throw new Error( + `never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`, + ); + return hits[0]; } const invariants = []; for (const n of nevers) { - if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); - taken.add(n.name); - const parts = n.states.map(resolve); - const regionsUsed = new Set(parts.map((p) => p.ri)); - if (regionsUsed.size !== parts.length) throw new Error(`never ${n.name} names two states of one region; one region is in one state at a time`); - const cond = parts.map((p) => (p.inside.length === 1 ? `${vars[p.ri].v} = ${str(p.inside[0])}` : `${vars[p.ri].v} \\in ${set(p.inside)}`)); - out.push(""); - if (n.sentence) out.push(`\\* ${n.sentence}`); - out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); - invariants.push(n.name); + if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); + taken.add(n.name); + const parts = n.states.map(resolve); + const regionsUsed = new Set(parts.map((p) => p.ri)); + if (regionsUsed.size !== parts.length) + throw new Error( + `never ${n.name} names two states of one region; one region is in one state at a time`, + ); + const cond = parts.map((p) => + p.inside.length === 1 + ? `${vars[p.ri].v} = ${str(p.inside[0])}` + : `${vars[p.ri].v} \\in ${set(p.inside)}`, + ); + out.push(""); + if (n.sentence) out.push(`\\* ${n.sentence}`); + out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); + invariants.push(n.name); } out.push("", "====", ""); -// TLC reports only the first rule a state breaks, so the config names no rule and each TLC run appends one. const rules = ["TypeOK", ...invariants]; -const cfg = ["SPECIFICATION Spec", `\\* Rules, one TLC run each: ${rules.join(" ")}`, ""]; +const cfg = ["SPECIFICATION Spec", ...rules.map((r) => `INVARIANT ${r}`), ""]; const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); const map = [ - `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, - `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, - `const LABELS = ${JSON.stringify(labels, null, "\t")};`, - "", - "// The actions the reader takes. facts.settled and --cover need them. Add cause() and detail() as the spec grows.", - "export const readerActions = [];", - 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', - "export const step = (action) => LABELS[action] ?? action;", - "", + `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, + `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, + `const LABELS = ${JSON.stringify(labels, null, "\t")};`, + "", + "// The actions the reader takes. facts.settled needs them. Add cause() and detail() as the spec grows.", + "export const readerActions = [];", + 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', + "export const step = (action) => LABELS[action] ?? action;", + "", ]; fs.mkdirSync(dir, { recursive: true }); @@ -165,5 +252,5 @@ fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); console.error( - `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, ); diff --git a/pstack/skills/statechart/scripts/compare-charts.mjs b/pstack/skills/statechart/scripts/compare-charts.mjs index 3a22f7187..d3de630e7 100644 --- a/pstack/skills/statechart/scripts/compare-charts.mjs +++ b/pstack/skills/statechart/scripts/compare-charts.mjs @@ -1,91 +1,104 @@ #!/usr/bin/env node -// Compare two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled transitions. -// -// node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd -// -// Use it to check that a spec still draws the chart the reader agreed to: EXPECTED is the agreed chart, -// ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer view with --highlight 0 --all-labels, -// without --cover or --fixed-dot. A chart with parallel regions compares as its combined machine -// (productOf in lib.mjs), so a guard that blocks a step in only some combinations of regions shows as a -// difference. A combined state is the region states joined by " | " in region order, which is what -// chart-to-tla.mjs's mapping writes. An edge label "a or b" counts as two labels. An unlabeled edge counts -// only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only the labels. -// Exits 1 when the charts differ, and 2 when it cannot read a chart or ACTUAL is not drawn for comparison. import fs from "node:fs"; import process from "node:process"; -import { parseMermaid, productOf } from "./lib.mjs"; +import { LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; -const [expectedFile, actualFile] = process.argv.slice(2); -if (!expectedFile || !actualFile) { - console.error("Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"); - process.exit(2); +const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"; +const HELP = `${USAGE} + +Compares two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled +transitions. statechart.mjs from-chart runs it to check that the spec still draws the chart that +the reader agreed to. + +EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer +view with --highlight 0 --all-labels --flat, without --cover, --fixed-dot, or --against-dot. Each +chart compares as the +machine that its steps reach from its start states. A chart with parallel regions compares as its +combined machine, so a guard that blocks a step in only some combinations of regions shows as a +difference. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition is its guard: +the step happens only while another region is in, or not in, one of those states. The label ends +with the guard's words in [ ], which are not part of the label. A combined state is the region +states joined by " | " in region order, which is what chart-to-tla.mjs's mapping writes. A step that does not change the state is not compared, because +tlc-to-chart.mjs does not draw it. An edge label "a or b" counts as two labels. An unlabeled edge +counts only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only +the labels. + + -h, --help Print this help, and exit 0. + +Prints each difference and then "N difference(s)", or prints "same". Writes no file. + +Exit codes + 0 The charts are the same. + 1 The charts differ. + 2 A bad argument, a chart that it cannot read, or an ACTUAL chart that is not drawn for + comparison.`; +const more = "Run node compare-charts.mjs --help for the flags."; + +const files = []; +for (const a of process.argv.slice(2)) { + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a.startsWith("-") || files.length === 2) { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } else files.push(a); } +if (files.length < 2) { + console.error(`${USAGE}\n${more}`); + process.exit(2); +} +const [expectedFile, actualFile] = files; -const machine = (regions) => { - const rs = Object.values(regions); - return rs.length === 1 ? rs[0] : productOf(regions); -}; -const read = (file) => { - try { - return parseMermaid(fs.readFileSync(file, "utf8")).regions; - } catch (e) { - console.error(`${file}: ${e.message}`); - process.exit(2); - } +const orExit = (file, f) => { + try { + return f(); + } catch (e) { + console.error(`${file}: ${e.message}`); + process.exit(2); + } }; -const expectedRegions = read(expectedFile); -const expected = machine(expectedRegions); -const actual = machine(read(actualFile)); +const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8"))); +const textRefusals = [ + [ + /^\s*%% second model/m, + "it has a second model; draw ACTUAL without --fixed-dot or --against-dot", + ], + [ + new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m"), + "it has boxes; draw ACTUAL with --flat", + ], +]; +for (const [bad, why] of textRefusals) { + if (bad.test(actualText)) { + console.error(`${actualFile}: ${why}`); + process.exit(2); + } +} +const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions); +const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions); +const drawn = Object.values(actualRegions).flatMap((r) => r.transitions); const refusals = [ - [(l) => / or …$/.test(l), "an edge label ends in \" or …\"; draw ACTUAL with --all-labels"], - [(l) => /^\d+(, \d+)*\. /.test(l), "an edge label is a numbered path step; draw ACTUAL with --highlight 0"], - [(l) => /with the fix\)?$|blocked by the fix$/.test(l), "an edge comes from the fixed model; draw ACTUAL without --fixed-dot"], + [(l) => / or …$/.test(l), 'an edge label ends in " or …"; draw ACTUAL with --all-labels'], + [ + (l) => /^\d+(, \d+)*\. /.test(l), + "an edge label is a numbered path step; draw ACTUAL with --highlight 0", + ], ]; for (const [bad, why] of refusals) { - if (actual.transitions.some((t) => bad(t.label))) { - console.error(`${actualFile}: ${why}`); - process.exit(2); - } + if (drawn.some((t) => bad(t.label))) { + console.error(`${actualFile}: ${why}`); + process.exit(2); + } } -const edgeSet = (m) => { - const labeled = new Set(); - const out = new Set(); - for (const t of m.transitions) { - if (!t.label) continue; - labeled.add(`${t.from} --> ${t.to}`); - for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); - } - for (const t of m.transitions) if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); - return out; -}; -const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); - -// A region state that no combination of the agreed chart's own steps reaches can never be drawn. -const unreached = []; -if (Object.keys(expectedRegions).length > 1) { - Object.entries(expectedRegions).forEach(([name, r], ri) => { - const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); - for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); - }); -} - -const rows = [ - ["region states the agreed chart's own steps never reach", unreached.sort()], - ["states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", minus(expected.states, actual.states)], - ["states only in ACTUAL", minus(actual.states, expected.states)], - ["start states only in EXPECTED", minus(expected.initial, actual.initial)], - ["start states only in ACTUAL", minus(actual.initial, expected.initial)], - ["transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", minus(edgeSet(expected), edgeSet(actual))], - ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], -].filter(([, xs]) => xs.length > 0); - +const rows = compareCharts(expectedRegions, actualRegions); let differences = 0; for (const [title, xs] of rows) { - console.log(`${title}:`); - for (const x of xs) console.log(` ${x}`); - differences += xs.length; + console.log(`${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; } console.log(differences === 0 ? "same" : `${differences} difference(s)`); process.exit(differences === 0 ? 0 : 1); diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index c1164ccf8..8fbac3be2 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -1,236 +1,301 @@ -// Shared parsing for the statechart scripts. Node built-ins only. +// Shared code for the statechart scripts. Node built-ins only. +import fs from "node:fs"; +import process from "node:process"; + +// ---------- Where TLC is ---------- + +// tla2tools.jar and the java binary, from TLA2TOOLS and JAVA. Throws where to get TLC when TLA2TOOLS is not set. +export function findTlc() { + const { TLA2TOOLS, JAVA } = process.env; + if (!TLA2TOOLS) + throw new Error( + "Cannot find TLC. Download tla2tools.jar from a TLA+ release:\n https://github.com/tlaplus/tlaplus/releases\nThen set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).", + ); + if (!fs.existsSync(TLA2TOOLS)) throw new Error(`TLA2TOOLS is ${TLA2TOOLS}, which does not exist.`); + return { jar: TLA2TOOLS, java: JAVA || "java" }; +} // ---------- TLA+ values as TLC prints them ---------- export function parseValue(text) { - const src = text.trim(); - let i = 0; - const ws = () => { - while (i < src.length && /\s/.test(src[i])) i++; - }; - const peek = (s) => src.startsWith(s, i); - const eat = (s) => { - ws(); - if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); - i += s.length; - }; - const list = (close, item) => { - const out = []; - ws(); - if (peek(close)) { - i += close.length; - return out; - } - for (;;) { - out.push(item()); - ws(); - if (peek(",")) { - i++; - continue; - } - eat(close); - return out; - } - }; - const value = () => { - ws(); - if (peek("<<")) { - i += 2; - return list(">>", value); - } - if (peek("{")) { - i++; - return list("}", value); - } - if (peek("[")) { - i++; - const pairs = list("]", () => { - ws(); - const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); - if (!m) throw new Error(`bad record field at ${i}`); - i += m[0].length; - eat("|->"); - return [m[0], value()]; - }); - return Object.fromEntries(pairs); - } - if (peek("(")) { - i++; - const fn = {}; - for (;;) { - const k = value(); - eat(":>"); - fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); - ws(); - if (peek("@@")) { - i += 2; - continue; - } - eat(")"); - return fn; - } - } - if (peek('"')) { - i++; - let s = ""; - while (i < src.length && src[i] !== '"') { - if (src[i] === "\\") i++; - s += src[i++]; - } - i++; - return s; - } - const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); - if (!m) throw new Error(`bad value at ${i} in ${src}`); - i += m[0].length; - if (m[0] === "TRUE") return true; - if (m[0] === "FALSE") return false; - return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; - }; - try { - const v = value(); - ws(); - return i === src.length ? v : src; - } catch { - return src; - } + const src = text.trim(); + let i = 0; + const ws = () => { + while (i < src.length && /\s/.test(src[i])) i++; + }; + const peek = (s) => src.startsWith(s, i); + const eat = (s) => { + ws(); + if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); + i += s.length; + }; + const list = (close, item) => { + const out = []; + ws(); + if (peek(close)) { + i += close.length; + return out; + } + for (;;) { + out.push(item()); + ws(); + if (peek(",")) { + i++; + continue; + } + eat(close); + return out; + } + }; + const value = () => { + ws(); + if (peek("<<")) { + i += 2; + return list(">>", value); + } + if (peek("{")) { + i++; + return list("}", value); + } + if (peek("[")) { + i++; + const pairs = list("]", () => { + ws(); + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad record field at ${i}`); + i += m[0].length; + eat("|->"); + return [m[0], value()]; + }); + return Object.fromEntries(pairs); + } + if (peek("(")) { + i++; + const fn = {}; + for (;;) { + const k = value(); + eat(":>"); + fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); + ws(); + if (peek("@@")) { + i += 2; + continue; + } + eat(")"); + return fn; + } + } + if (peek('"')) { + i++; + let s = ""; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") i++; + s += src[i++]; + } + i++; + return s; + } + const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad value at ${i} in ${src}`); + i += m[0].length; + if (m[0] === "TRUE") return true; + if (m[0] === "FALSE") return false; + return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; + }; + try { + const v = value(); + ws(); + return i === src.length ? v : src; + } catch { + return src; + } } // One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. export function parseState(text) { - const lines = text.split("\n").filter((l) => l.trim() !== ""); - const conjuncts = []; - for (const line of lines) { - if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) conjuncts.push(line.replace(/^\s*\/\\ /, "")); - else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; - } - const vars = {}; - for (const c of conjuncts) { - const eq = c.indexOf(" = "); - if (eq < 0) continue; - vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); - } - return vars; + const lines = text.split("\n").filter((l) => l.trim() !== ""); + const conjuncts = []; + for (const line of lines) { + if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) + conjuncts.push(line.replace(/^\s*\/\\ /, "")); + else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; + } + const vars = {}; + for (const c of conjuncts) { + const eq = c.indexOf(" = "); + if (eq < 0) continue; + vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); + } + return vars; } -export function stateKey(vars) { - return JSON.stringify(Object.keys(vars).sort().map((k) => [k, vars[k]])); +export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const canon = (v) => + Array.isArray(v) + ? v.map(canon) + : isRecord(v) + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, canon(v[k])]), + ) + : v; +// Record fields sort at every depth, so a state keeps its key when a field is added back. +export const stateKey = (vars) => JSON.stringify(canon(vars)); + +// The rule sentence of each definition: the \* comment lines right above NAME ==. +export function ruleSentences(tla) { + const out = new Map(); + const lines = tla.split("\n"); + lines.forEach((line, i) => { + const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1]; + if (!name) return; + const said = []; + for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--) + said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim()); + const sentence = said.filter(Boolean).join(" "); + if (sentence) out.set(name, sentence); + }); + return out; } // ---------- TLC -dump dot,actionlabels ---------- function unescapeDot(s) { - let out = ""; - for (let i = 0; i < s.length; i++) { - if (s[i] === "\\" && i + 1 < s.length) { - const n = s[++i]; - out += n === "n" ? "\n" : n; - } else out += s[i]; - } - return out; + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const n = s[++i]; + out += n === "n" ? "\n" : n; + } else out += s[i]; + } + return out; } // Returns { nodes, edges, loops }. loops are steps that change no variable; they count as enabled // actions but are not transitions. export function parseDot(text) { - const nodes = new Map(); - const edges = []; - const loops = []; - const seen = new Set(); - const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; - const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); - const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); - for (const line of text.split("\n")) { - let m = edgeRe.exec(line); - if (m) { - const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; - const id = `${e.from} ${e.to} ${e.action}`; - if (seen.has(id)) continue; - seen.add(id); - (e.from === e.to ? loops : edges).push(e); - continue; - } - m = nodeRe.exec(line); - if (m) nodes.set(m[1], { id: m[1], vars: parseState(unescapeDot(m[2])), init: /style\s*=\s*filled/.test(m[3]) }); - } - if (nodes.size === 0) throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); - const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); - if (missing) throw new Error(`transition ${missing.from} -> ${missing.to} names a state the file does not define`); - if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) throw new Error("the graph has no action names; dump it with TLC -dump dot,actionlabels "); - return { nodes, edges, loops }; + const nodes = new Map(); + const edges = []; + const loops = []; + const seen = new Set(); + const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; + const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); + const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); + for (const line of text.split("\n")) { + let m = edgeRe.exec(line); + if (m) { + const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; + const id = `${e.from} ${e.to} ${e.action}`; + if (seen.has(id)) continue; + seen.add(id); + (e.from === e.to ? loops : edges).push(e); + continue; + } + m = nodeRe.exec(line); + if (m) + nodes.set(m[1], { + id: m[1], + vars: parseState(unescapeDot(m[2])), + init: /style\s*=\s*filled/.test(m[3]), + }); + } + if (nodes.size === 0) + throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); + const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + if (missing) + throw new Error( + `transition ${missing.from} -> ${missing.to} names a state the file does not define`, + ); + if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) + throw new Error( + "the graph has no action names; dump it with TLC -dump dot,actionlabels ", + ); + return { nodes, edges, loops }; } // ---------- TLC error traces (one per violation; many with -continue) ---------- // A trace that follows an error other than a broken invariant has invariant null. export function parseTraces(text) { - const traces = []; - let invariant = null; - let current = null; - const lines = text.split("\n"); - const stateAt = (i) => { - const body = []; - while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); - return [parseState(body.join("\n")), i]; - }; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); - if (inv) { - invariant = inv[1]; - if (inv[2]) { - const [vars, end] = stateAt(i); - traces.push({ invariant, steps: [{ action: null, vars }] }); - i = end; - } - continue; - } - if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) invariant = null; - const head = /^State (\d+): <([^\s>(]+)/.exec(line); - if (!head) continue; - const [vars, end] = stateAt(i); - i = end; - if (head[1] === "1") { - current = { invariant, steps: [] }; - traces.push(current); - } - current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); - } - return traces; + const traces = []; + let invariant = null; + let current = null; + const lines = text.split("\n"); + const stateAt = (i) => { + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + return [parseState(body.join("\n")), i]; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); + if (inv) { + invariant = inv[1]; + if (inv[2]) { + const [vars, end] = stateAt(i); + traces.push({ invariant, steps: [{ action: null, vars }] }); + i = end; + } + continue; + } + if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) + invariant = null; + const head = /^State (\d+): <([^\s>(]+)/.exec(line); + if (!head) continue; + const [vars, end] = stateAt(i); + i = end; + if (head[1] === "1") { + current = { invariant, steps: [] }; + traces.push(current); + } + current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); + } + return traces; } // Throws unless TLC finished and every error it printed is a broken invariant. With -continue, TLC // prints "Model checking completed" even after broken invariants, and not when it stopped early. export function checkTlcOutput(text, file) { - const other = text - .split("\n") - .find((l) => l.startsWith("Error: ") && !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l)); - if (other) throw new Error(`${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`); - if (!/^Model checking completed\./m.test(text)) throw new Error(`${file}: TLC did not finish, so its counterexamples are not all there; read the file`); + const other = text + .split("\n") + .find( + (l) => + l.startsWith("Error: ") && + !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l), + ); + if (other) + throw new Error( + `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`, + ); + if (!/^Model checking completed\./m.test(text)) + throw new Error( + `${file}: TLC did not finish, so its counterexamples are not all there; read the file`, + ); } // ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- export function wrap(text, width = 26) { - const words = String(text).split(/\s+/); - const rows = []; - let row = ""; - for (const w of words) { - if (row && (row + " " + w).length > width) { - rows.push(row); - row = w; - } else row = row ? `${row} ${w}` : w; - } - if (row) rows.push(row); - return rows.join("
"); + const words = String(text).split(/\s+/); + const rows = []; + let row = ""; + for (const w of words) { + if (row && (row + " " + w).length > width) { + rows.push(row); + row = w; + } else row = row ? `${row} ${w}` : w; + } + if (row) rows.push(row); + return rows.join("
"); } const unwrap = (s) => - s - .replace(//g, " ") - .replace(/#(quot|34);/g, '"') - .replace(/\s+/g, " ") - .trim(); + s + .replace(//g, " ") + .replace(/#(quot|34);/g, '"') + .replace(/\s+/g, " ") + .trim(); // Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. export const quote = (s) => String(s).replaceAll('"', "#quot;"); @@ -238,240 +303,449 @@ export const quote = (s) => String(s).replaceAll('"', "#quot;"); // The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; -// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label}] } }, nevers }. +// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label,guard?}] } }, nevers }. // Composite states flatten to "Parent.Child". A transition on a composite leaves from each state // inside it that has no transition with the same label, and enters at the composite's [*] start. // One top-level composite split by "--" makes one region per part. Regions nest only at the top. +// Words in [ ] in a label say when the step can happen. They are not part of the label, and nothing +// checks them. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition guards it +// on the states of other regions; its label must have the guard's words in [ ]. +// guard is { not, states: { region: [state] } }. export function parseMermaid(text) { - const names = new Map(); - const nevers = []; - const newPart = () => ({ children: [], transitions: [] }); - const root = { name: "", parts: [newPart()] }; - const stack = [root]; - const part = () => stack[stack.length - 1].parts.at(-1); - const display = (id) => names.get(id) ?? id; - // As in Mermaid, a state that only transitions name belongs to the part where it first appears. - const implicit = new Map(); - const declare = (id) => { - const prev = implicit.get(id); - if (prev) prev.children.splice(prev.children.findIndex((c) => c.name === id), 1); - implicit.set(id, null); - }; - const mention = (id) => { - if (id === "[*]" || implicit.has(id)) return; - part().children.push({ name: id, leaf: true }); - implicit.set(id, part()); - }; - for (const raw of text.split("\n")) { - const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); - const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); - if (never) { - nevers.push({ name: never[1], states: never[2].split("&").map((s) => s.trim()), sentence: never[3] ?? "" }); - continue; - } - if (!line || line.startsWith("%%") || /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line)) continue; - let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); - if (!m) { - const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); - if (plain) m = [line, null, plain[1], plain[2]]; - } - if (m) { - if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); - declare(m[2]); - if (m[3]) { - const node = { name: m[2], parts: [newPart()] }; - part().children.push(node); - stack.push(node); - } else part().children.push({ name: m[2], leaf: true }); - continue; - } - if (line === "}") { - if (stack.length === 1) throw new Error("unmatched } in the chart"); - stack.pop(); - continue; - } - if (line === "--") { - stack[stack.length - 1].parts.push(newPart()); - continue; - } - m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); - if (m) { - if (m[2] === "[*]" && m[1] !== "[*]") throw new Error(`cannot read final state in "${line}"; draw the step to the state the reader sees next`); - mention(m[1]); - mention(m[2]); - part().transitions.push({ from: m[1], to: m[2], label: m[3] ? unwrap(m[3]) : "" }); - continue; - } - m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); - if (m) { - // Mermaid shows every description line; the first one names the state. - if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); - if (implicit.get(m[1]) === null) continue; - declare(m[1]); - part().children.push({ name: m[1], leaf: true }); - continue; - } - throw new Error(`cannot read chart line: ${line}`); - } - if (stack.length !== 1) throw new Error("the chart has an unclosed {"); - if (root.parts.length > 1) throw new Error("the chart has -- outside a composite; to make parallel regions, split one top-level composite with --"); + const names = new Map(); + const nevers = []; + let last = null; + const newPart = () => ({ children: [], transitions: [] }); + const root = { name: "", parts: [newPart()] }; + const stack = [root]; + const part = () => stack[stack.length - 1].parts.at(-1); + const display = (id) => names.get(id) ?? id; + // As in Mermaid, a state that only transitions name belongs to the part where it first appears. + const implicit = new Map(); + const declare = (id) => { + const prev = implicit.get(id); + if (prev) + prev.children.splice( + prev.children.findIndex((c) => c.name === id), + 1, + ); + implicit.set(id, null); + }; + const mention = (id) => { + if (id === "[*]" || implicit.has(id)) return; + part().children.push({ name: id, leaf: true }); + implicit.set(id, part()); + }; + for (const raw of text.split("\n")) { + const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); + const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); + if (when) { + if (!last) throw new Error(`a %% when line must come right after a transition: ${line}`); + if (!last.words) + throw new Error( + `the guarded step "${last.label}" must end its label with the guard's words in [ ]`, + ); + last.when = { not: Boolean(when[1]), ids: when[2].split(",").map((s) => s.trim()) }; + last = null; + continue; + } + last = null; + const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); + if (never) { + nevers.push({ + name: never[1], + states: never[2].split("&").map((s) => s.trim()), + sentence: never[3] ?? "", + }); + continue; + } + if ( + !line || + line.startsWith("%%") || + /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line) + ) + continue; + let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (!m) { + const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (plain) m = [line, null, plain[1], plain[2]]; + } + if (m) { + if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); + declare(m[2]); + if (m[3]) { + const node = { name: m[2], parts: [newPart()] }; + part().children.push(node); + stack.push(node); + } else part().children.push({ name: m[2], leaf: true }); + continue; + } + if (line === "}") { + if (stack.length === 1) throw new Error("unmatched } in the chart"); + stack.pop(); + continue; + } + if (line === "--") { + stack[stack.length - 1].parts.push(newPart()); + continue; + } + m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); + if (m) { + if (m[2] === "[*]" && m[1] !== "[*]") + throw new Error( + `cannot read final state in "${line}"; draw the step to the state the reader sees next`, + ); + mention(m[1]); + mention(m[2]); + const said = m[3] ? unwrap(m[3]) : ""; + const label = said.replace(/\s*\[[^\]]*\]/g, ""); + last = { from: m[1], to: m[2], label, words: label !== said }; + part().transitions.push(last); + continue; + } + m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); + if (m) { + // Mermaid shows every description line; the first one names the state. + if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); + if (implicit.get(m[1]) === null) continue; + declare(m[1]); + part().children.push({ name: m[1], leaf: true }); + continue; + } + throw new Error(`cannot read chart line: ${line}`); + } + if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + if (root.parts.length > 1) + throw new Error( + "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --", + ); - // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. - // A composite's full name prefixes its children ("Parent.Child"). An id used only in a - // transition is a plain state in the part where it first appears. - function region(p) { - const out = { states: new Set(), initial: new Set(), transitions: [] }; - const info = new Map(); - const declared = new Set(); - (function collect(q) { - for (const c of q.children) { - if (c.name === LEGEND_ID) continue; - declared.add(c.name); - if (!c.leaf) for (const sub of c.parts) collect(sub); - } - })(p); - const transitions = []; - const lookup = (id) => { - const found = info.get(id); - if (!found) throw new Error(`transition names ${id}, which is not in this region`); - return found; - }; - function states(q, prefix, depth) { - const local = q.children.filter((c) => c.name !== LEGEND_ID); - for (const t of q.transitions) { - for (const end of [t.from, t.to]) { - if (end === "[*]" || declared.has(end)) continue; - declared.add(end); - local.push({ name: end, leaf: true }); - } - } - const all = []; - for (const c of local) { - if (info.has(c.name)) { - if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; - throw new Error(`state id ${c.name} is declared in two places`); - } - const full = `${prefix}${display(c.name)}`; - if (c.leaf) { - if (out.states.has(full)) throw new Error(`two states have the caption "${full}"; give each its own caption`); - out.states.add(full); - info.set(c.name, { all: [full], initial: [full], depth }); - all.push(full); - continue; - } - if (c.parts.length > 1) throw new Error(`${display(c.name)} splits into regions below the top level; make each region a top-level region`); - const inner = states(c.parts[0], `${full}.`, depth + 1); - const initial = c.parts[0].transitions.filter((t) => t.from === "[*]").flatMap((t) => lookup(t.to).initial); - if (initial.length === 0) throw new Error(`${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`); - info.set(c.name, { all: inner, initial, depth }); - all.push(...inner); - } - for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); - return all; - } - states(p, "", 0); - for (const t of p.transitions) if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); - // The deepest source wins: a composite's transition leaves only from states inside it - // that have no deeper transition with the same label. - transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); - const handled = new Set(); - let pending = []; - let depth = null; - for (const t of transitions) { - const from = lookup(t.from); - if (from.depth !== depth) { - for (const h of pending) handled.add(h); - pending = []; - depth = from.depth; - } - for (const f of from.all) { - const key = `${f}\u0000${t.label}`; - if (t.label && handled.has(key)) continue; - for (const to of lookup(t.to).initial) out.transitions.push({ from: f, to, label: t.label }); - if (t.label) pending.push(key); - } - } - return out; - } + // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. + // A composite's full name prefixes its children ("Parent.Child"). An id used only in a + // transition is a plain state in the part where it first appears. + function region(p) { + const out = { states: new Set(), initial: new Set(), transitions: [] }; + const info = new Map(); + const declared = new Set(); + (function collect(q) { + for (const c of q.children) { + if (c.name === LEGEND_ID) continue; + declared.add(c.name); + if (!c.leaf) for (const sub of c.parts) collect(sub); + } + })(p); + const transitions = []; + const lookup = (id) => { + const found = info.get(id); + if (!found) throw new Error(`transition names ${id}, which is not in this region`); + return found; + }; + function states(q, prefix, depth) { + const local = q.children.filter((c) => c.name !== LEGEND_ID); + for (const t of q.transitions) { + for (const end of [t.from, t.to]) { + if (end === "[*]" || declared.has(end)) continue; + declared.add(end); + local.push({ name: end, leaf: true }); + } + } + const all = []; + for (const c of local) { + if (info.has(c.name)) { + if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; + throw new Error(`state id ${c.name} is declared in two places`); + } + const full = `${prefix}${display(c.name)}`; + if (c.leaf) { + if (out.states.has(full)) + throw new Error(`two states have the caption "${full}"; give each its own caption`); + out.states.add(full); + info.set(c.name, { all: [full], initial: [full], depth }); + all.push(full); + continue; + } + if (c.parts.length > 1) + throw new Error( + `${display(c.name)} splits into regions below the top level; make each region a top-level region`, + ); + const inner = states(c.parts[0], `${full}.`, depth + 1); + const initial = c.parts[0].transitions + .filter((t) => t.from === "[*]") + .flatMap((t) => lookup(t.to).initial); + if (initial.length === 0) + throw new Error( + `${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`, + ); + info.set(c.name, { all: inner, initial, depth }); + all.push(...inner); + } + for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); + return all; + } + states(p, "", 0); + for (const t of p.transitions) + if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); + // The deepest source wins: a composite's transition leaves only from states inside it + // that have no deeper transition with the same label. + transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); + const handled = new Set(); + let pending = []; + let depth = null; + for (const t of transitions) { + const from = lookup(t.from); + if (from.depth !== depth) { + for (const h of pending) handled.add(h); + pending = []; + depth = from.depth; + } + for (const f of from.all) { + const key = `${f}\u0000${t.label}`; + if (t.label && handled.has(key)) continue; + for (const to of lookup(t.to).initial) + out.transitions.push({ + from: f, + to, + label: t.label, + ...(t.when ? { when: t.when } : {}), + }); + if (t.label) pending.push(key); + } + } + for (const [id, { all }] of info) owners.set(id, { name: regionName, all }); + return out; + } + const owners = new Map(); + let regionName = ""; - const regions = {}; - const top = root.parts[0]; - const machine = top.children.filter((c) => c.name !== LEGEND_ID); - const parallel = machine.length === 1 && !machine[0].leaf && machine[0].parts.length > 1 && top.transitions.every((t) => t.from === "[*]"); - if (parallel) { - for (const p of machine[0].parts) { - const [only] = p.children; - const holder = p.children.length === 1 && !only.leaf && p.transitions.every((t) => t.from === "[*]" && t.to === only.name) ? only : null; - if (holder && holder.parts.length > 1) throw new Error(`${display(holder.name)} splits into regions below the top level`); - regions[holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`] = region(holder ? holder.parts[0] : p); - } - } else regions.Main = region(top); - return { regions, nevers }; + const regions = {}; + const top = root.parts[0]; + const machine = top.children.filter((c) => c.name !== LEGEND_ID); + const parallel = + machine.length === 1 && + !machine[0].leaf && + machine[0].parts.length > 1 && + top.transitions.every((t) => t.from === "[*]"); + if (parallel) { + for (const p of machine[0].parts) { + const [only] = p.children; + const holder = + p.children.length === 1 && + !only.leaf && + p.transitions.every((t) => t.from === "[*]" && t.to === only.name) + ? only + : null; + if (holder && holder.parts.length > 1) + throw new Error(`${display(holder.name)} splits into regions below the top level`); + regionName = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; + regions[regionName] = region(holder ? holder.parts[0] : p); + } + } else { + regionName = "Main"; + regions.Main = region(top); + } + for (const [name, r] of Object.entries(regions)) + for (const t of r.transitions) { + if (!t.when) continue; + if (!parallel) + throw new Error("a %% when line guards a step on other regions, and the chart has none"); + const states = {}; + for (const id of t.when.ids) { + const at = owners.get(id); + if (!at) + throw new Error(`a %% when line names ${id}, which is not a state id in the chart`); + if (at.name === name) + throw new Error( + `a %% when line names ${display(id)}, which is in the step's own region; a guard reads other regions`, + ); + states[at.name] = [...new Set([...(states[at.name] ?? []), ...at.all])].sort(); + } + t.guard = { not: t.when.not, states }; + delete t.when; + } + return { regions, nevers }; +} + +// Whether a guard lets a step happen while the regions are in the states that `at` maps them to. +export function guardHolds(guard, at) { + if (!guard) return true; + const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r])); + return guard.not ? !hits.some(Boolean) : hits.every(Boolean); } // The combined machine of a chart's regions, as chart-to-tla.mjs generates it. A labeled step can // happen when at least one region can take it; every region that can take it moves, the others stay. +// A region can take a transition only while its guard holds in the state before the step. // An unlabeled transition moves its own region only. A combined state is the region states joined // by " | " in region order. Only states reachable from the start are kept, and steps that change // nothing are dropped, as TLC's state graph drops them. export function productOf(regions) { - const rs = Object.values(regions); - const join = (parts) => parts.join(" | "); - const byLabel = new Map(); - const unlabeled = []; - rs.forEach((r, ri) => { - for (const t of r.transitions) { - if (!t.label) { - unlabeled.push({ ri, from: t.from, to: t.to }); - continue; - } - if (!byLabel.has(t.label)) byLabel.set(t.label, rs.map(() => new Map())); - const moves = byLabel.get(t.label)[ri]; - if (!moves.has(t.from)) moves.set(t.from, new Set()); - moves.get(t.from).add(t.to); - } - }); - let starts = [[]]; - for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); - const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; - const seen = new Set(starts.map(join)); - const queue = [...starts]; - const step = (from, to, label) => { - const [a, b] = [join(from), join(to)]; - if (a === b) return; - out.transitions.push({ from: a, to: b, label }); - if (!seen.has(b)) { - seen.add(b); - queue.push(to); - } - }; - while (queue.length) { - const cur = queue.shift(); - out.states.add(join(cur)); - for (const u of unlabeled) { - if (cur[u.ri] !== u.from) continue; - const next = [...cur]; - next[u.ri] = u.to; - step(cur, next, ""); - } - for (const [label, perRegion] of byLabel) { - if (!perRegion.some((moves, ri) => moves.has(cur[ri]))) continue; - let nexts = [[]]; - perRegion.forEach((moves, ri) => { - const tos = moves.has(cur[ri]) ? [...moves.get(cur[ri])] : [cur[ri]]; - nexts = nexts.flatMap((p) => tos.map((s) => [...p, s])); - }); - for (const next of nexts) step(cur, next, label); - } - } - return out; + const names = Object.keys(regions); + const rs = Object.values(regions); + const join = (parts) => parts.join(" | "); + const byLabel = new Map(); + const unlabeled = []; + rs.forEach((r, ri) => { + for (const t of r.transitions) { + if (!t.label) { + unlabeled.push({ ri, from: t.from, to: t.to, guard: t.guard }); + continue; + } + if (!byLabel.has(t.label)) + byLabel.set( + t.label, + rs.map(() => new Map()), + ); + const moves = byLabel.get(t.label)[ri]; + if (!moves.has(t.from)) moves.set(t.from, []); + moves.get(t.from).push({ to: t.to, guard: t.guard }); + } + }); + let starts = [[]]; + for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); + const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; + const seen = new Set(starts.map(join)); + const queue = [...starts]; + const step = (from, to, label) => { + const [a, b] = [join(from), join(to)]; + if (a === b) return; + out.transitions.push({ from: a, to: b, label }); + if (!seen.has(b)) { + seen.add(b); + queue.push(to); + } + }; + while (queue.length) { + const cur = queue.shift(); + out.states.add(join(cur)); + const at = Object.fromEntries(names.map((n, ri) => [n, cur[ri]])); + for (const u of unlabeled) { + if (cur[u.ri] !== u.from || !guardHolds(u.guard, at)) continue; + const next = [...cur]; + next[u.ri] = u.to; + step(cur, next, ""); + } + for (const [label, perRegion] of byLabel) { + const open = perRegion.map((moves, ri) => + (moves.get(cur[ri]) ?? []).filter((m) => guardHolds(m.guard, at)).map((m) => m.to), + ); + if (open.every((tos) => tos.length === 0)) continue; + let nexts = [[]]; + open.forEach((tos, ri) => { + const options = tos.length ? [...new Set(tos)] : [cur[ri]]; + nexts = nexts.flatMap((p) => options.map((s) => [...p, s])); + }); + for (const next of nexts) step(cur, next, label); + } + } + return out; +} + +// Compares two charts as the machines their steps reach. Returns [title, differences] rows, only +// the rows that have differences. An edge label "a or b" counts as two labels. An unlabeled edge +// counts only when no labeled edge joins the same two states. +export function compareCharts(expectedRegions, actualRegions) { + const expected = productOf(expectedRegions); + const actual = productOf(actualRegions); + const edgeSet = (m) => { + const labeled = new Set(); + const out = new Set(); + for (const t of m.transitions) { + if (!t.label) continue; + labeled.add(`${t.from} --> ${t.to}`); + for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); + } + for (const t of m.transitions) + if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); + return out; + }; + const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + // A region state that no combination of the agreed chart's own steps reaches can never be drawn. + const unreached = []; + const regionEntries = Object.entries(expectedRegions); + if (regionEntries.length === 1) { + for (const s of regionEntries[0][1].states) if (!expected.states.has(s)) unreached.push(s); + } else { + regionEntries.forEach(([name, r], ri) => { + const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); + for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); + }); + } + return [ + ["states the agreed chart's own steps never reach", unreached.sort()], + [ + "states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", + minus(expected.states, actual.states), + ], + ["states only in ACTUAL", minus(actual.states, expected.states)], + ["start states only in EXPECTED", minus(expected.initial, actual.initial)], + ["start states only in ACTUAL", minus(actual.initial, expected.initial)], + [ + "transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", + minus(edgeSet(expected), edgeSet(actual)), + ], + ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], + ].filter(([, xs]) => xs.length > 0); } export const STATUS_CLASSES = [ - "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", - "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", - "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", - "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", - "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", - "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", + "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", + "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", + "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", + "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", + "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", ]; + +// Fills for the mapping's groups that it gives no color: light, and not red, green, or gray, so a +// status border stays clear on each. +export const GROUP_PALETTE = [ + "#d6eaf8", + "#fdebd0", + "#e8daef", + "#fcf3cf", + "#d1f2eb", + "#ede0d4", + "#f6e0ef", +]; +export const PLAIN_FILL = "#f4f6f7"; + +const rgb = (hex) => { + const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); +}; +// WCAG relative luminance: 0 for black, 1 for white. +export function luminance(hex) { + const [r, g, b] = rgb(hex).map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +// Why a group color cannot fill a state, or null when it can. +export function groupColorProblem(color) { + if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) + return 'is not "#rgb" or "#rrggbb"'; + if (luminance(color) < 0.5) return "is too dark for the status borders to show on it"; + return null; +} + +// A status border sits on top of the group fill. The path border shows only on states that work. +const BORDERS = { + Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"], + Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"], + Path: ["stroke:#1f3a93,stroke-width:4px", "#1b2631"], +}; +export function groupClassDef(base, fill, border) { + const darker = `#${rgb(fill) + .map((c) => + Math.round(c / 2) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; + const [stroke, color] = BORDERS[border] ?? [ + `stroke:${fill === PLAIN_FILL ? "#566573" : darker}`, + "#1b2631", + ]; + return `classDef ${base}${border} fill:${fill},${stroke},color:${color}`; +} diff --git a/pstack/skills/statechart/scripts/statechart.mjs b/pstack/skills/statechart/scripts/statechart.mjs new file mode 100644 index 000000000..b69911ae0 --- /dev/null +++ b/pstack/skills/statechart/scripts/statechart.mjs @@ -0,0 +1,541 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { checkTlcOutput, findTlc, parseDot } from "./lib.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +const USAGE = { + "from-spec": + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", + "from-chart": + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", +}; +const usage = (lines) => `Usage: ${lines.join("\n ")}`; + +const FROM_SPEC = `from-spec SPEC.tla + Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state + breaks, and the separate runs find the rest. SPEC.cfg sits next to the spec and has SPECIFICATION + and one INVARIANT line per rule. One more run dumps the state graph, and then the command draws + the charts. + + --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and + module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks + it the same way, with FIXED.cfg, or with SPEC.cfg when there is no FIXED.cfg. + The charts then mark each bad state fixed or still wrong. + --against AGREED.tla + The spec that SPEC.tla must follow, when SPEC.tla models the code. It needs + its own file and module name. The command checks it with AGREED.cfg, or with + SPEC.cfg when there is no AGREED.cfg. SPEC.tla may add variables, record + fields, and actions that AGREED.tla does not have. The comparison leaves them + out. Do not use it with --fixed. + --label TEXT The name of the --fixed or --against model in the charts and the stories. + Default: "the fix", or "the spec". + --map FILE The labels for each model state and step. Default: SPEC.map.mjs next to the + spec. When the file does not exist, the command writes one to fill in and + exits 2. Its comments say what each function gets.`; + +const FROM_CHART = `from-chart CHART.mmd + Writes NAME.tla, NAME.cfg, and NAME.map.mjs next to the chart. When NAME.tla already exists, it + writes none of them. Then it checks the spec as from-spec does, and compares the chart that the + spec draws with CHART.mmd. + + --module NAME The TLA+ module name. Default: the chart's file name in CamelCase, such as + NotesApp for notes-app.mmd. + + The chart is a Mermaid stateDiagram-v2. Each transition label is one action. For parallel + regions, split one top-level composite with --, and put each part in a named composite, such as + state Connection { ... }. Each region is one variable. A step moves every region whose current + state has a transition with the step's label, and the other regions stay where they are. Each + rule is one comment line: + %% never NAME: A & B -- the rule in one sentence + Each name after the colon is a state caption or a composite, and each comes from a different + region. A composite stands for every state in it. The command refuses final, choice, fork, and + history states, and regions below the top level.`; + +const COMMON = `Both commands + --expect-broken RULE + A rule that is known to break. Repeat it. The command then exits 0 when + exactly these rules break, and the --fixed or --against model breaks no other + rule. + --title TEXT The chart title. Each bug chart adds ", bug N". + --out DIR The output folder. Default: NAME-statechart next to the input. + --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. + -h, --help Print this help, and exit 0. + +Output + The command prints whether each rule holds, the chart's counts, and one story per bug. + from-chart also prints the comparison. The command writes these files in the output folder: + chart.mmd The overview. + bug-N.mmd Only the steps of bug N, numbered. Bugs are numbered by fewest reader steps, + then fewest model steps, then rule name, then cause. Bugs that only the + --fixed or --against model has come last. + bug-N-developer.mmd, bug-N-layered.mmd + The path of bug N through the developer's states, when the mapping exports + detail(). + stories.md One entry per bug: the rule, why it breaks, the shortest path in reader + steps, and what the --fixed or --against model does instead. + summary.json The counts behind the chart. + tlc/ The config and the TLC output of each run. When the overview has regions, + from-spec also writes tlc/regions-all.mmd, the regions with every arrow, and + tlc/regions-proof.mmd, every state and step of TLC's graph in one region. + compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the + proof. + *.png Each chart as a PNG, with --png. + +Reading a chart + from-spec draws the overview as two parallel regions when every state reads "context: progress": + the contexts on one side, the progress on the other, both at the same time. It does so only when + that has fewer states, and when the regions combine back into exactly every state and step of + TLC's graph. Otherwise it puts the states of one context in a box named context, and prints why. + Regions draw only the arrows of the shortest way to each state, of each bug path, and of the + nearest step with each label. The script checks that these arrows make no step that TLC's graph + does not have. + An arrow from a box's border leaves from every state in the box. + In regions, a step leaves every region that has it. "[when A]" or "[unless A]" says which states + of the other region the step needs. A loop says the region may also stay. + Other words in [ ] come from the mapping's guard(), and "after N s" from its timer(). TLC does + not check them, and has no clock, so a reviewer checks them against the code. + Gray states work. Red states break a rule. With --fixed, red states stay wrong with the fix, and + green states are fixed by it or exist only with it. With --against, red states break a rule in + either model, and green states exist only in the spec. + When the mapping exports group(), the fill is the state's group, which the legend names, and the + status is a thick red or green border on top. tlc-to-chart.mjs --help tells more. + A state is bad when it breaks a rule, or when every path from the start to it passes a state + that breaks one. + A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an + edge. + An edge labeled "with the fix" or "in the spec" leaves the state before the step that the other + model changes, and ends where that model goes instead. "blocked by the fix" or "not in the spec" + loops on the state where that model stops the step. "(with the fix)" or "(in the spec)" marks an + edge that only the other model has. + An edge shows its two most common labels. "or …" means that there are more. + +Regenerate + With --expect-broken, one script rebuilds every chart and stops on any change: + set -e + node statechart.mjs from-spec Code.tla --against Agreed.tla --expect-broken NoLostUpload --png + +TLC + TLA2TOOLS is the path to tla2tools.jar, and JAVA is the java binary (default: java, Java 11 or + later). Download tla2tools.jar from https://github.com/tlaplus/tlaplus/releases. When TLA2TOOLS + is not set, the command says where to get TLC and exits 2. + +Exit codes + 0 Every rule holds, or exactly the --expect-broken rules break. from-chart also draws CHART.mmd + back unchanged. + 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not + CHART.mmd. The output says which. + 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it + cannot read, no TLC, or a new mapping to fill in.`; + +const HELP = { + "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} + +Draws a Mermaid statechart of what an app's user sees, from TLC's state graph of a TLA+ spec. The +help calls that user the reader. + +${FROM_SPEC} + +${FROM_CHART} + +${COMMON}`, + "from-spec": `${usage([USAGE["from-spec"]])}\n\n${FROM_SPEC}\n\n${COMMON}`, + "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`, +}; + +function fail(message) { + console.error(message); + process.exit(2); +} + +const [command, ...rest] = process.argv.slice(2); +const more = (cmd) => + `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; +if (command === "-h" || command === "--help") { + console.log(HELP[""]); + process.exit(0); +} +const FLAGS = { + "from-spec": ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + "from-chart": ["--module", "--expect-broken", "--title", "--out"], +}; +if (!FLAGS[command]) fail(`${usage(Object.values(USAGE))}\n${more()}`); +const args = { "expect-broken": [] }; +let input; +for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "-h" || a === "--help") { + console.log(HELP[command]); + process.exit(0); + } else if (a === "--png") args.png = true; + else if (FLAGS[command].includes(a)) { + if (i + 1 === rest.length) fail(`${a} needs a value. ${more(command)}`); + const name = a.slice(2); + if (Array.isArray(args[name])) args[name].push(rest[++i]); + else args[name] = rest[++i]; + } else if (!a.startsWith("-") && input === undefined) input = a; + else fail(`unknown argument ${a}. ${more(command)}`); +} +if (input === undefined) fail(`${usage([USAGE[command]])}\n${more(command)}`); +if (args.fixed && args.against) fail(`use --fixed or --against, not both. ${more(command)}`); +if (args.label !== undefined && !args.fixed && !args.against) + fail(`--label names the --fixed or --against model; it needs one of them. ${more(command)}`); +const expected = new Set(args["expect-broken"]); + +const rel = (file) => path.relative(process.cwd(), file) || "."; +let tools; +function needTlc() { + try { + tools = findTlc(); + } catch (e) { + fail(e.message); + } +} + +// The child scripts throw on input they cannot use. Print the message and the file it is about, not the stack. +function script(name, scriptArgs, about) { + const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { + encoding: "utf8", + }); + if (r.status === 0 || (name === "compare-charts.mjs" && r.status === 1)) return r; + const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; + fail(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); +} + +const KEYWORD = + /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/; + +// The config without its INVARIANT sections, and the rules they name. +function splitRules(cfg) { + const words = cfg + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " ") + .split(/\s+/) + .filter(Boolean); + const kept = []; + const rules = []; + let section = ""; + for (const w of words) { + if (KEYWORD.test(w)) section = w; + if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); + else if (w !== section && !rules.includes(w)) rules.push(w); + } + return { base: `${kept.join(" ")}\n`, rules }; +} + +function tlc(spec, cfg, base, dump) { + fs.writeFileSync(`${base}.cfg`, cfg); + const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); + // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. + const r = spawnSync( + tools.java, + [ + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + metadir, + "-deadlock", + "-continue", + ...(dump ? ["-dump", "dot,actionlabels", path.resolve(dump)] : []), + "-config", + path.resolve(`${base}.cfg`), + path.basename(spec), + ], + { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 }, + ); + fs.rmSync(metadir, { recursive: true, force: true }); + if (r.error) fail(`cannot run TLC: ${r.error.message}`); + fs.writeFileSync(`${base}.out`, r.stdout); + try { + checkTlcOutput(r.stdout, rel(`${base}.out`)); + } catch (e) { + fail(e.message); + } + return r.stdout; +} + +// main is true for the input spec: --expect-broken names its rules. +function check(spec, cfgFile, tlcDir, main) { + const name = path.basename(spec, ".tla"); + if (!fs.existsSync(spec)) fail(`${rel(spec)} does not exist`); + if (!fs.existsSync(cfgFile)) + fail(`${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`); + const { base, rules } = splitRules(fs.readFileSync(cfgFile, "utf8")); + const unknown = main ? [...expected].filter((r) => !rules.includes(r)) : []; + if (unknown.length) + fail( + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(cfgFile)}, whose rules are ${rules.join(", ") || "none"}.`, + ); + const dot = path.join(tlcDir, `${name}.dot`); + tlc(spec, base, path.join(tlcDir, name), dot); + console.log(`TLC on ${path.basename(spec)}:`); + if (rules.length === 0) + console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); + const traces = rules.map((rule) => { + const file = path.join(tlcDir, `${name}-${rule}`); + const broken = /^Error: Invariant /m.test(tlc(spec, `${base}INVARIANT ${rule}\n`, file)); + console.log( + ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, + ); + return { file: `${file}.out`, rule, broken }; + }); + return { spec, dot, traces, broken: traces.some((t) => t.broken) }; +} + +// Without --expect-broken, every rule must hold. With it, exactly those rules break in the input +// spec, and the other model breaks no other rule. +function rulesAsExpected(now, other) { + const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); + if (expected.size === 0) return !now.broken && !other?.broken; + const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( + (r) => !expected.has(r), + ); + const holds = [...expected].filter((r) => !brokenIn(now).includes(r)); + if (unexpected.length) console.log(`\nBroken but not expected: ${unexpected.join(", ")}.`); + if (holds.length) console.log(`\nExpected to break but holds: ${holds.join(", ")}.`); + if (!unexpected.length && !holds.length) + console.log(`\nBroken as expected: ${[...expected].join(", ")}.`); + return !unexpected.length && !holds.length; +} + +const SCAFFOLD = (name, graph) => { + const vars = Object.keys([...graph.nodes.values()][0]?.vars ?? {}); + const actions = [...new Set([...graph.edges, ...graph.loops].map((e) => e.action))].sort(); + return `// Chart labels for ${name}, for statechart.mjs. Write labels in plain words, not variable or action names. +// v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind. +// Sequences, tuples, and sets become arrays, indexed from 0. Records and functions become objects. +// Variables: ${vars.join(", ")}. + +// The actions the reader takes. facts.settled is true when only these are enabled, and the overview +// keeps the shortest path to each settled state, plus every bug path. +export const readerActions = []; + +// The label for one model state. facts.enabled is the set of actions the state enables, +// including steps that change no variable. Write it as "context: progress", such as +// "Offline: upload queued": the chart puts the states of one context in one box. +// Do not claim a result that a pending step can still change, such as "upload failed" while a +// retry is still queued. +export function state(v, facts) { + return JSON.stringify(v); +} + +// The names of the two parallel regions, when the overview draws them: contexts, then progress. +// export const regionNames = ["Network", "What happened to the file"]; + +// The group of one model state, in words the reader sees, such as "Upload done". The chart fills +// the states of one group with one color, and the legend names the groups. TLC still decides +// which states break a rule, and draws them with a thick red border on top of the group color. +// groupColors gives a group a light color. Groups without one take a palette color. +// export function group(v, facts) {} +// export const groupColors = { "Upload done": "#d1f2eb" }; + +// The label for one step. A step that leaves the reader in the same state is not drawn, +// unless the reader takes it on a bug path. "" draws no label, and a numbered bug step shows the action. +const STEPS = { +${actions.map((a) => `\t${a}: ${JSON.stringify(a)},`).join("\n")} +}; +export function step(action, before, after) { + return STEPS[action] ?? action; +} + +// When the step can happen, in plain words, such as "offline or disk full". The chart shows +// "step [words]". "" adds nothing. +// export function guard(action, before, after) {} + +// For a timer step, how many seconds the code waits, from the constant in the code. The chart +// shows "after N s, step". Return nothing for other steps. +// export function timer(action, before, after) {} + +// Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and +// the app drops an upload it cannot send". No variable, function, or event names. +// Counterexamples that break one rule with one cause are one bug, so two bugs that end on the +// same screen stay apart only if their causes differ. +// export function cause(v, facts, invariant) {} + +// The developer's words for one model state. With it, each bug is also drawn through these states. +// export function detail(v, facts) {} +`; +}; + +async function draw({ now, second, role, map, out, comparing }) { + const mapping = await import(pathToFileURL(path.resolve(map)).href); + const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; + from.push(...now.traces.flatMap((t) => ["--traces", t.file])); + if (second) { + from.push(`--${role}-dot`, second.dot, "--spec", second.spec); + from.push(...second.traces.flatMap((t) => [`--${role}-traces`, t.file])); + if (args.label !== undefined) from.push("--label", args.label); + } + // compare-charts.mjs needs every edge, every label, and no boxes, and --cover drops edges. + if (comparing) from.push("--all-labels", "--flat"); + else if (mapping.readerActions?.length) from.push("--cover"); + const titled = (suffix) => (args.title ? ["--title", `${args.title}${suffix}`] : []); + for (const f of fs.readdirSync(out)) + if (/^bug-\d+(-developer|-layered)?\.(mmd|png)$/.test(f)) fs.rmSync(path.join(out, f)); + + const files = ["chart.mmd", "stories.md", "summary.json"].map((f) => path.join(out, f)); + const proof = path.join(out, "tlc", "regions-proof.mmd"); + const everyArrow = path.join(out, "tlc", "regions-all.mmd"); + for (const f of [proof, everyArrow]) fs.rmSync(f, { force: true }); + script( + "tlc-to-chart.mjs", + [ + ...from, + ...(comparing ? [] : ["--regions", "--proof", proof, "--regions-all", everyArrow]), + ...titled(""), + "--highlight", + "0", + "--out", + files[0], + "--story", + files[1], + "--summary", + files[2], + ], + map, + ); + const summary = JSON.parse(fs.readFileSync(files[2], "utf8")); + const charts = [files[0]]; + for (const c of summary.classes) { + const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; + for (const view of views) { + const file = path.join(out, `bug-${c.n}${view}.mmd`); + const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"]; + script( + "tlc-to-chart.mjs", + [...from, ...shape, ...titled(`, bug ${c.n}`), "--highlight", String(c.n), "--out", file], + map, + ); + charts.push(file); + } + } + + const { works, wrong, fixed: fixedCount } = summary.chartStatus; + const L = summary.second?.label; + console.log( + `\nChart: ${summary.chartStates} states and ${summary.chartTransitions} transitions from ${summary.modelStates} model states.`, + ); + const r = summary.regions; + if (r?.drawn) + console.log( + `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 ${rel(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${rel(everyArrow)} has all ${r.all} arrows.`, + ); + else if (r) console.log(`It has no parallel regions, because ${r.why}.`); + const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; + console.log( + role === "fixed" + ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n` + : role === "against" + ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n` + : `${works} work, ${breaks}.\n`, + ); + if (summary.codeLike.length) + console.log( + `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`, + ); + console.log( + summary.classes.length ? fs.readFileSync(files[1], "utf8").trimEnd() : "No rule is broken.", + ); + return { files, charts }; +} + +function render(charts) { + // Mermaid shrinks a wide chart to the 800px page; keep its natural size so labels stay readable. + const config = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")), "mermaid.json"); + fs.writeFileSync(config, JSON.stringify({ state: { useMaxWidth: false } })); + const pngs = []; + for (const chart of charts) { + const png = chart.replace(/\.mmd$/, ".png"); + const r = spawnSync( + "npx", + ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2", "-c", config], + { encoding: "utf8" }, + ); + if (r.status === 0) pngs.push(png); + else + console.error( + `could not render ${rel(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, + ); + } + return pngs; +} + +function finish(out, charts, files, ok) { + const pngs = args.png ? render(charts) : []; + const written = [...charts, ...pngs, ...files.slice(1)].map((f) => path.basename(f)).join(", "); + console.log(`\nWrote ${written} in ${rel(out)}.`); + process.exit(ok ? 0 : 1); +} + +const outDir = (name) => { + const out = args.out ?? path.join(path.dirname(input), `${name}-statechart`); + fs.mkdirSync(path.join(out, "tlc"), { recursive: true }); + return out; +}; + +if (command === "from-spec") { + const name = path.basename(input, ".tla"); + const role = args.fixed ? "fixed" : args.against ? "against" : null; + const other = args.fixed ?? args.against; + // TLC's output files are named after the spec, so a second model with the same name overwrites them. + if (other && path.basename(other, ".tla") === name) + fail( + `${rel(other)} needs its own file and module name, such as ${name}${role === "fixed" ? "Fixed" : "Agreed"}.tla.`, + ); + needTlc(); + const out = outDir(name); + const map = args.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const now = check(input, input.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); + fail(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); + } + let second = null; + if (other) { + const cfg = other.replace(/\.tla$/, ".cfg"); + second = check( + other, + fs.existsSync(cfg) ? cfg : input.replace(/\.tla$/, ".cfg"), + path.join(out, "tlc"), + ); + } + const { files, charts } = await draw({ now, second, role, map, out }); + finish(out, charts, files, rulesAsExpected(now, second)); +} else { + const name = + args.module ?? + path + .basename(input, path.extname(input)) + .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase()); + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) + fail(`cannot make a TLA+ module name from ${input}; add --module NAME`); + const spec = path.join(path.dirname(input), `${name}.tla`); + if (fs.existsSync(spec)) { + console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); + } else { + script("chart-to-tla.mjs", [input, "--module", name], input); + console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); + } + needTlc(); + const out = outDir(name); + const now = check(spec, spec.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); + const { files, charts } = await draw({ + now, + map: spec.replace(/\.tla$/, ".map.mjs"), + out, + comparing: true, + }); + const compared = script("compare-charts.mjs", [input, files[0]]); + if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); + else + console.log( + `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + ); + finish(out, charts, files, rulesAsExpected(now) && compared.status === 0); +} diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 981a9ffdb..545d76a23 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -1,39 +1,59 @@ // node --test scripts/test/statechart.test.mjs -// Set TLA2TOOLS (path to tla2tools.jar) and JAVA (java binary, default "java") to run the TLC tests too. +// The TLC tests run when statechart.mjs can find TLC. Its --help says where it looks. import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { checkTlcOutput, parseDot, parseMermaid, parseTraces, parseValue } from "../lib.mjs"; +import { + checkTlcOutput, + findTlc, + parseDot, + parseMermaid, + parseTraces, + parseValue, + ruleSentences, + stateKey, +} from "../lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const scripts = dirname(here); const fixture = (name) => readFileSync(join(here, name), "utf8"); const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); -const node = (script, args, cwd) => spawnSync(process.execPath, [join(scripts, script), ...args], { cwd, encoding: "utf8" }); +const node = (script, args, cwd, env = {}) => + spawnSync(process.execPath, [join(scripts, script), ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + }); test("a transition on a composite leaves from every state inside it", () => { - const { regions } = parseMermaid(fixture("upload.mmd")); - const main = regions.Main; - assert.deepEqual([...main.states].sort(), ["Nothing chosen", "Upload failed", "Uploaded", "Uploading.Checking the file", "Uploading.Sending the file"]); - assert.deepEqual([...main.initial], ["Nothing chosen"]); - assert.deepEqual(edges(main), [ - "Nothing chosen -> Uploading.Sending the file : you choose a file", - "Upload failed -> Uploading.Sending the file : you retry", - "Uploading.Checking the file -> Nothing chosen : you cancel", - "Uploading.Checking the file -> Upload failed : the network drops", - "Uploading.Checking the file -> Uploaded : the check passes", - "Uploading.Sending the file -> Nothing chosen : you cancel", - "Uploading.Sending the file -> Upload failed : the network drops", - "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", - ]); + const { regions } = parseMermaid(fixture("upload.mmd")); + const main = regions.Main; + assert.deepEqual([...main.states].sort(), [ + "Nothing chosen", + "Upload failed", + "Uploaded", + "Uploading.Checking the file", + "Uploading.Sending the file", + ]); + assert.deepEqual([...main.initial], ["Nothing chosen"]); + assert.deepEqual(edges(main), [ + "Nothing chosen -> Uploading.Sending the file : you choose a file", + "Upload failed -> Uploading.Sending the file : you retry", + "Uploading.Checking the file -> Nothing chosen : you cancel", + "Uploading.Checking the file -> Upload failed : the network drops", + "Uploading.Checking the file -> Uploaded : the check passes", + "Uploading.Sending the file -> Nothing chosen : you cancel", + "Uploading.Sending the file -> Upload failed : the network drops", + "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", + ]); }); test("the deepest source wins when a composite and a state inside it share a label", () => { - const { regions } = parseMermaid(`stateDiagram-v2 + const { regions } = parseMermaid(`stateDiagram-v2 state Form { [*] --> Typing Typing --> Confirm : you press Escape @@ -42,47 +62,84 @@ test("the deepest source wins when a composite and a state inside it share a lab [*] --> Form Form --> Closed : you press Escape Confirm --> Closed : you press Escape`); - assert.deepEqual(edges(regions.Main), ["Form.Confirm -> Closed : you press Escape", "Form.Typing -> Form.Confirm : you press Escape"]); + assert.deepEqual(edges(regions.Main), [ + "Form.Confirm -> Closed : you press Escape", + "Form.Typing -> Form.Confirm : you press Escape", + ]); }); test("a top-level composite split by -- makes one region per part", () => { - const { regions, nevers } = parseMermaid(fixture("sync.mmd")); - assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); - assert.deepEqual(edges(regions["Sync badge"]), [ - "Synced -> Waiting to sync : the network drops", - "Synced -> Waiting to sync : you edit a note", - "Waiting to sync -> Synced : the server confirms", - ]); - assert.deepEqual(nevers, [{ name: "NeverSyncedWhileOffline", states: ["Offline", "Synced"], sentence: 'You never see "Synced" while you are offline.' }]); + const { regions, nevers } = parseMermaid(fixture("sync.mmd")); + assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); + assert.deepEqual(edges(regions["Sync badge"]), [ + "Synced -> Waiting to sync : the network drops", + "Synced -> Waiting to sync : you edit a note", + "Waiting to sync -> Synced : the server confirms", + ]); + assert.deepEqual(nevers, [ + { + name: "NeverSyncedWhileOffline", + states: ["Offline", "Synced"], + sentence: 'You never see "Synced" while you are offline.', + }, + ]); +}); + +test("a rule's sentence is the comment lines right above it, and a state key ignores field order", () => { + const sentences = ruleSentences(`---- MODULE M ---- +\\* Not about a rule. + +\\* You never see "Saved" +\\* while the note is a draft. +NoFalseSaved == TRUE +Bare == TRUE +====`); + assert.deepEqual( + [...sentences], + [["NoFalseSaved", 'You never see "Saved" while the note is a draft.']], + ); + assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 })); }); test("the legend is not part of the machine", () => { - const { regions } = parseMermaid(`stateDiagram-v2 + const { regions } = parseMermaid(`stateDiagram-v2 state "Legend" as legend { state "Works today" as legendWorks } [*] --> A A --> B : go`); - assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); }); test("the parser refuses charts it cannot read", () => { - assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); - assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); - assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), /no \[\*\] --> start/); - assert.throws( - () => parseMermaid("stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A"), - /below the top level/, - ); - assert.throws(() => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), /final state/); - assert.throws( - () => parseMermaid('stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go'), - /two states have the caption "Something went wrong"/, - ); + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); + assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); + assert.throws( + () => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), + /no \[\*\] --> start/, + ); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A", + ), + /below the top level/, + ); + assert.throws( + () => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), + /final state/, + ); + assert.throws( + () => + parseMermaid( + 'stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go', + ), + /two states have the caption "Something went wrong"/, + ); }); test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => { - const { regions } = parseMermaid(`stateDiagram-v2 + const { regions } = parseMermaid(`stateDiagram-v2 classDef wrong fill:#fdd N : Nothing chosen N : press Upload to start @@ -96,34 +153,47 @@ test("the parser reads class shorthand, repeated descriptions, and states first Moving --> A : start Y --> Z : next A --> N : cancel`); - assert.deepEqual([...regions.Main.states].sort(), ["A.X", "A.Y", "Done", "Moving", "Nothing chosen", "Z"]); - assert.deepEqual(edges(regions.Main), [ - "A.X -> A.Y : go", - "A.X -> Nothing chosen : cancel", - "A.Y -> Nothing chosen : cancel", - "A.Y -> Z : next", - "Done -> Moving : ", - "Moving -> A.X : start", - "Nothing chosen -> Done : you finish", - ]); + assert.deepEqual([...regions.Main.states].sort(), [ + "A.X", + "A.Y", + "Done", + "Moving", + "Nothing chosen", + "Z", + ]); + assert.deepEqual(edges(regions.Main), [ + "A.X -> A.Y : go", + "A.X -> Nothing chosen : cancel", + "A.Y -> Nothing chosen : cancel", + "A.Y -> Z : next", + "Done -> Moving : ", + "Moving -> A.X : start", + "Nothing chosen -> Done : you finish", + ]); }); test("TLC values, states, graphs, and traces parse", () => { - assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); - assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); - const graph = parseDot(`strict digraph DiskGraph { + assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); + assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); + const graph = parseDot(`strict digraph DiskGraph { 1 [label="/\\\\ x = 0",style = filled] 2 [label="/\\\\ x = 1"] 1 -> 2 [label="Inc",color="black",fontcolor="black"]; 1 -> 2 [label="Inc",color="black",fontcolor="black"]; 2 -> 2 [label="Stay"]; }`); - assert.equal(graph.nodes.get("1").init, true); - assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); - assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); - assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); - assert.throws(() => parseDot('strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}'), /no action names/); - const [trace] = parseTraces(`Error: Invariant Small is violated. + assert.equal(graph.nodes.get("1").init, true); + assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); + assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); + assert.throws( + () => + parseDot( + 'strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}', + ), + /no action names/, + ); + const [trace] = parseTraces(`Error: Invariant Small is violated. Error: The behavior up to this point is: State 1: x = 0 @@ -131,8 +201,11 @@ x = 0 State 2: x = 1 `); - assert.equal(trace.invariant, "Small"); - assert.deepEqual(trace.steps, [{ action: null, vars: { x: 0 } }, { action: "Inc", vars: { x: 1 } }]); + assert.equal(trace.invariant, "Small"); + assert.deepEqual(trace.steps, [ + { action: null, vars: { x: 0 } }, + { action: "Inc", vars: { x: 1 } }, + ]); }); const done = "Model checking completed. No error has been found.\n"; @@ -158,76 +231,124 @@ s = "Saving" `; test("a rule broken by a start state is a counterexample of one state", () => { - const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: + const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: /\\ badge = "Synced" /\\ conn = "Offline" ${done}`; - assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); - assert.deepEqual(parseTraces(text), [{ invariant: "NeverSyncedWhileOffline", steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }] }]); + assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.deepEqual(parseTraces(text), [ + { + invariant: "NeverSyncedWhileOffline", + steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }], + }, + ]); }); test("TLC output from a run that failed or stopped early is refused", () => { - assert.doesNotThrow(() => checkTlcOutput(`Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, "ok.out")); - assert.throws(() => checkTlcOutput("Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", "cfg.out"), /cfg\.out: TLC stopped on an error that is not a broken rule/); - assert.throws(() => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), /not a broken rule/); - assert.throws(() => checkTlcOutput(evaluationError, "eval.out"), /TLC threw an unexpected exception/); - assert.throws(() => checkTlcOutput("Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", "stopped.out"), /did not finish/); - assert.deepEqual( - parseTraces(evaluationError).map((t) => t.invariant), - ["NoLost", null], - ); + assert.doesNotThrow(() => + checkTlcOutput( + `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, + "ok.out", + ), + ); + assert.throws( + () => + checkTlcOutput( + "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", + "cfg.out", + ), + /cfg\.out: TLC stopped on an error that is not a broken rule/, + ); + assert.throws( + () => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), + /not a broken rule/, + ); + assert.throws( + () => checkTlcOutput(evaluationError, "eval.out"), + /TLC threw an unexpected exception/, + ); + assert.throws( + () => + checkTlcOutput( + "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", + "stopped.out", + ), + /did not finish/, + ); + assert.deepEqual( + parseTraces(evaluationError).map((t) => t.invariant), + ["NoLost", null], + ); }); test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync( - join(dir, "save.dot"), - `strict digraph DiskGraph { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "save.dot"), + `strict digraph DiskGraph { 1 [label="/\\\\ s = \\"Idle\\"",style = filled] 2 [label="/\\\\ s = \\"Saving\\""] 1 -> 2 [label="Tap"]; 2 -> 2 [label="Retry"]; }`, - ); - writeFileSync( - join(dir, "save.map.mjs"), - `export const readerActions = ["Tap"]; + ); + writeFileSync( + join(dir, "save.map.mjs"), + `export const readerActions = ["Tap"]; export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving"); export const step = (action) => ({ Tap: "you tap Save" })[action] ?? ""; `, - ); - const r = node("tlc-to-chart.mjs", ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], dir); - assert.equal(r.status, 0, r.stderr); - assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), ['Badge says "Synced" -> Saving : you tap Save']); + ); + const r = node( + "tlc-to-chart.mjs", + ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), [ + 'Badge says "Synced" -> Saving : you tap Save', + ]); }); test("chart-to-tla writes one action per label and one invariant per never line", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); - const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); - assert.equal(r.status, 0, r.stderr); - const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); - assert.match(spec, /^VARIABLES connection, syncBadge$/m); - assert.match(spec, /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m); - assert.match(spec, /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m); - assert.equal(readFileSync(join(dir, "Sync.cfg"), "utf8"), "SPECIFICATION Spec\n\\* Rules, one TLC run each: TypeOK NeverSyncedWhileOffline\n"); - assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + assert.match(spec, /^VARIABLES connection, syncBadge$/m); + assert.match( + spec, + /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m, + ); + assert.match( + spec, + /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m, + ); + assert.equal( + readFileSync(join(dir, "Sync.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); }); test("chart-to-tla refuses a never line that names no state", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "c.mmd"), "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n"); - const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); - assert.notEqual(r.status, 0); - assert.match(r.stderr, /names "C", which is not a state/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /names "C", which is not a state/); }); test("a never line names a state exactly, or every state inside a composite", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync( - join(dir, "c.mmd"), - `stateDiagram-v2 + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + `stateDiagram-v2 state "Saving" as S state "Saving..." as S2 state Busy { @@ -241,13 +362,16 @@ test("a never line names a state exactly, or every state inside a composite", () %% never NoSaving: Saving -- You never see Saving. %% never NoBusy: Busy -- You are never busy. `, - ); - const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); - assert.equal(r.status, 0, r.stderr); - const spec = readFileSync(join(dir, "C.tla"), "utf8"); - assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); - assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); - assert.match(readFileSync(join(dir, "C.map.mjs"), "utf8"), /^export const readerActions = \[\];$/m); + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "C.tla"), "utf8"); + assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); + assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); + assert.match( + readFileSync(join(dir, "C.map.mjs"), "utf8"), + /^export const readerActions = \[\];$/m, + ); }); const door = `stateDiagram-v2 @@ -262,137 +386,600 @@ const door = `stateDiagram-v2 } [*] --> Door`; const flatDoor = (extra = []) => - [ - "stateDiagram-v2", - ' state "Locked | Closed" as L_C', - ' state "Locked | Open" as L_O', - ' state "Unlocked | Closed" as U_C', - ' state "Unlocked | Open" as U_O', - " [*] --> L_C", - " L_C --> U_C : tap unlock", - " U_C --> L_C : tap lock", - " L_O --> U_O : tap unlock", - " L_C --> L_O : open the door", - " L_O --> L_C : close the door", - " U_C --> U_O : open the door", - " U_O --> U_C : close the door", - ...extra, - ].join("\n"); + [ + "stateDiagram-v2", + ' state "Locked | Closed" as L_C', + ' state "Locked | Open" as L_O', + ' state "Unlocked | Closed" as U_C', + ' state "Unlocked | Open" as U_O', + " [*] --> L_C", + " L_C --> U_C : tap unlock", + " U_C --> L_C : tap lock", + " L_O --> U_O : tap unlock", + " L_C --> L_O : open the door", + " L_O --> L_C : close the door", + " U_C --> U_O : open the door", + " U_O --> U_C : close the door", + ...extra, + ].join("\n"); test("compare reads parallel regions as one machine, so a guard across regions shows", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "agreed.mmd"), door); - writeFileSync(join(dir, "guarded.mmd"), flatDoor()); - writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); - const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); - assert.equal(differ.status, 1); - assert.match(differ.stdout, /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/); - const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); - assert.equal(same.stdout.trim(), "same", same.stdout); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + writeFileSync(join(dir, "guarded.mmd"), flatDoor()); + writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); + const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); + assert.equal(differ.status, 1); + assert.match( + differ.stdout, + /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/, + ); + const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); + assert.equal(same.stdout.trim(), "same", same.stdout); +}); + +const lamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : warm up [when the
power is on] + %% when On + Lit --> Dark : cool down [when the power is off] + %% when not On + } + } + %% never LitWhileOff: Off & Lit -- The bulb is never lit while the power is off. +`; + +test("a %% when line guards a step on the other region's states, and [words] are only its text", () => { + const { regions } = parseMermaid(lamp); + assert.deepEqual(regions.Bulb.transitions, [ + { from: "Dark", to: "Lit", label: "warm up", guard: { not: false, states: { Power: ["On"] } } }, + { + from: "Lit", + to: "Dark", + label: "cool down", + guard: { not: true, states: { Power: ["On"] } }, + }, + ]); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + writeFileSync( + join(dir, "flat.mmd"), + [ + "stateDiagram-v2", + " [*] --> OD", + ' state "Off | Dark" as OD', + ' state "On | Dark" as ND', + ' state "On | Lit" as NL', + ' state "Off | Lit" as OL', + " OD --> ND : flip the switch", + " ND --> OD : flip the switch", + " ND --> NL : warm up", + " NL --> OL : flip the switch", + " OL --> NL : flip the switch", + " OL --> OD : cool down", + ].join("\n"), + ); + const same = node("compare-charts.mjs", ["lamp.mmd", "flat.mmd"], dir); + assert.equal(same.stdout, "same\n", same.stderr); + const tla = node("chart-to-tla.mjs", ["lamp.mmd", "--module", "Lamp"], dir); + assert.equal(tla.status, 0, tla.stderr); + const spec = readFileSync(join(dir, "Lamp.tla"), "utf8"); + assert.match(spec, /\(bulb = "Dark" \/\\ power = "On" \/\\ bulb' = "Lit"\)/); + assert.match(spec, /\(bulb = "Lit" \/\\ ~\(power = "On"\) \/\\ bulb' = "Dark"\)/); + + for (const [from, to, why] of [ + [ + " %% when On\n", + " state Glow\n %% when On\n", + /must come right after a transition/, + ], + [ + "warm up [when the
power is on]", + "warm up", + /must end its label with the guard's words in \[ \]/, + ], + [" %% when On\n", " %% when Nope\n", /names Nope, which is not a state id/], + [" %% when On\n", " %% when Lit\n", /names Lit, which is in the step's own region/], + ]) + assert.throws(() => parseMermaid(lamp.replace(from, to)), why); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n [*] --> A\n A --> B : go [when C]\n %% when C\n B --> C : on\n", + ), + /the chart has none/, + ); + assert.deepEqual( + edges(parseMermaid("stateDiagram-v2\n [*] --> A\n A --> B : go [user away]\n").regions.Main), + ["A -> B : go"], + ); +}); + +const switchLamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : flip the switch [when the power is off] + %% when Off + Lit --> Dark : flip the switch + } + } +`; + +test("a label with a guarded and an unguarded move in one region writes both moves", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("chart-to-tla.mjs", ["switch.mmd", "--module", "Switch"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Switch.tla"), "utf8"); + assert.equal( + spec.slice(spec.indexOf("\\* flip the switch"), spec.indexOf("\nNext ==")), + `\\* flip the switch +FlipTheSwitch == + /\\ (power \\in {"Off", "On"} \\/ (bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) + /\\ \\/ (power = "Off" /\\ power' = "On") + \\/ (power = "On" /\\ power' = "Off") + \\/ (power \\notin {"Off", "On"} /\\ UNCHANGED power) + /\\ \\/ (bulb = "Dark" /\\ power = "Off" /\\ bulb' = "Lit") + \\/ (bulb = "Lit" /\\ bulb' = "Dark") + \\/ (~((bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) /\\ UNCHANGED bulb) +`, + ); +}); + +test("compare reads a one-region chart as the machine its steps reach, without steps that change nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const drawn = + "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy : start\n Busy --> Idle : finish\n"; + writeFileSync(join(dir, "drawn.mmd"), drawn); + writeFileSync(join(dir, "loop.mmd"), `${drawn} Idle --> Idle : refresh\n`); + writeFileSync(join(dir, "orphan.mmd"), `${drawn} Lost --> Idle : come back\n`); + const same = node("compare-charts.mjs", ["loop.mmd", "drawn.mmd"], dir); + assert.equal(same.status, 0); + assert.equal(same.stdout, "same\n"); + const orphan = node("compare-charts.mjs", ["orphan.mmd", "drawn.mmd"], dir); + assert.equal(orphan.status, 1); + assert.equal( + orphan.stdout, + "states the agreed chart's own steps never reach:\n Lost\n1 difference(s)\n", + ); }); test("a -- outside a composite is refused, not read as one region", () => { - const split = "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; - assert.throws(() => parseMermaid(split), /-- outside a composite/); - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "split.mmd"), split); - writeFileSync(join(dir, "door.mmd"), door); - const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); - assert.notEqual(tla.status, 0); - assert.match(tla.stderr, /-- outside a composite/); - for (const pair of [["split.mmd", "split.mmd"], ["door.mmd", "split.mmd"], ["split.mmd", "door.mmd"]]) { - const r = node("compare-charts.mjs", pair, dir); - assert.equal(r.status, 2, pair.join(" ")); - assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); - } + const split = + "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; + assert.throws(() => parseMermaid(split), /-- outside a composite/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "split.mmd"), split); + writeFileSync(join(dir, "door.mmd"), door); + const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); + assert.notEqual(tla.status, 0); + assert.match(tla.stderr, /-- outside a composite/); + for (const pair of [ + ["split.mmd", "split.mmd"], + ["door.mmd", "split.mmd"], + ["split.mmd", "door.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); + } }); test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "agreed.mmd"), door); - for (const pair of [["agreed.mmd", "missing.mmd"], ["missing.mmd", "agreed.mmd"]]) { - const r = node("compare-charts.mjs", pair, dir); - assert.equal(r.status, 2, pair.join(" ")); - assert.match(r.stderr, /^missing\.mmd: ENOENT/); - } - for (const [label, why] of [ - ["tap lock or …", /--all-labels/], - ["1. tap lock", /--highlight 0/], - ["tap lock (with the fix)", /without --fixed-dot/], - ["tap lock, blocked by the fix", /without --fixed-dot/], - ]) { - writeFileSync(join(dir, "actual.mmd"), flatDoor([` U_O --> L_O : ${label}`])); - const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); - assert.equal(r.status, 2, label); - assert.match(r.stderr, why); - } + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + for (const pair of [ + ["agreed.mmd", "missing.mmd"], + ["missing.mmd", "agreed.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^missing\.mmd: ENOENT/); + } + for (const [extra, why] of [ + [" U_O --> L_O : tap lock or …", /--all-labels/], + [" U_O --> L_O : 1. tap lock", /--highlight 0/], + [ + " %% second model (against): the spec", + "actual.mmd: it has a second model; draw ACTUAL without --fixed-dot or --against-dot\n", + ], + [ + ' state "Locked" as g1 {\n state "Closed" as L_C2\n }', + "actual.mmd: it has boxes; draw ACTUAL with --flat\n", + ], + ]) { + writeFileSync(join(dir, "actual.mmd"), flatDoor([extra])); + const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); + assert.equal(r.status, 2, extra); + if (typeof why === "string") assert.equal(r.stderr, why); + else assert.match(r.stderr, why); + } +}); + +const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: + https://github.com/tlaplus/tlaplus/releases +Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). +`; +const FROM_SPEC_USAGE = + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const FROM_CHART_USAGE = + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const noTlc = { TLA2TOOLS: "", JAVA: "" }; + +test("statechart.mjs writes the spec next to the chart, keeps it on the next run, and stops without TLC", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + const first = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal(first.status, 2); + assert.equal( + first.stdout, + "Wrote NotesApp.tla, NotesApp.cfg, and NotesApp.map.mjs from notes-app.mmd.\n", + ); + assert.equal(first.stderr, NO_TLC); + assert.equal( + readFileSync(join(dir, "NotesApp.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + const again = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal( + again.stdout, + "Kept NotesApp.tla. Delete it to write the spec again from notes-app.mmd.\n", + ); + const spec = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, noTlc); + assert.equal(spec.status, 2); + assert.equal(spec.stderr, NO_TLC); + const missingJar = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, { + TLA2TOOLS: "/nonexistent/tla2tools.jar", + }); + assert.equal(missingJar.status, 2); + assert.equal( + missingJar.stderr, + "TLA2TOOLS is /nonexistent/tla2tools.jar, which does not exist.\n", + ); +}); + +test("statechart.mjs names the chart it refuses and the argument it does not know", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const refused = node("statechart.mjs", ["from-chart", "c.mmd"], dir); + assert.equal(refused.status, 2); + assert.equal(refused.stdout, ""); + assert.equal(refused.stderr, 'c.mmd: never line names "C", which is not a state in the chart\n'); + const unknown = node("statechart.mjs", ["from-chart", "c.mmd", "--fixed", "CFixed.tla"], dir); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "unknown argument --fixed. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const sameName = node("statechart.mjs", ["from-spec", "C.tla", "--fixed", "fix/C.tla"], dir); + assert.equal(sameName.status, 2); + assert.equal( + sameName.stderr, + "fix/C.tla needs its own file and module name, such as CFixed.tla.\n", + ); + const sameAgreed = node("statechart.mjs", ["from-spec", "C.tla", "--against", "a/C.tla"], dir); + assert.equal( + sameAgreed.stderr, + "a/C.tla needs its own file and module name, such as CAgreed.tla.\n", + ); + for (const [extra, stderr] of [ + [ + ["--fixed", "F.tla", "--against", "A.tla"], + "use --fixed or --against, not both. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + ["--label", "the new build"], + "--label names the --fixed or --against model; it needs one of them. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + ]) { + const r = node("statechart.mjs", ["from-spec", "C.tla", ...extra], dir); + assert.equal(r.status, 2, extra.join(" ")); + assert.equal(r.stderr, stderr); + } + const valueless = node("statechart.mjs", ["from-chart", "c.mmd", "--out"], dir); + assert.equal(valueless.status, 2); + assert.equal( + valueless.stderr, + "--out needs a value. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const bare = node("statechart.mjs", [], dir); + assert.equal(bare.status, 2); + assert.equal( + bare.stderr, + `Usage: ${FROM_SPEC_USAGE} + ${FROM_CHART_USAGE} +Run node statechart.mjs --help for the flags, the output, and the exit codes. +`, + ); }); -const jar = process.env.TLA2TOOLS; -const java = process.env.JAVA ?? "java"; -const needsTlc = { skip: jar ? false : "set TLA2TOOLS to run TLC" }; - -// Without a rule, dump NAME.dot. With a rule, check it alone in NAME-RULE.out, as the SKILL.md loop does. -function tlc(dir, name, rule) { - const base = rule ? `${name}-${rule}` : name; - if (rule) writeFileSync(join(dir, `${base}.cfg`), `${readFileSync(join(dir, `${name}.cfg`), "utf8")}\nINVARIANT ${rule}\n`); - const dump = rule ? [] : ["-dump", "dot,actionlabels", `${name}.dot`]; - const r = spawnSync( - java, - ["-cp", jar, "tlc2.TLC", "-metadir", mkdtempSync(join(tmpdir(), "tlc-")), "-deadlock", "-continue", ...dump, "-config", `${base}.cfg`, `${name}.tla`], - { cwd: dir, encoding: "utf8" }, - ); - writeFileSync(join(dir, `${base}.out`), r.stdout); - return r; +test("every script prints its help with -h or --help, and points at it for an argument it does not know", () => { + const help = { + "statechart.mjs": [[], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-spec": [["from-spec"], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-chart": [["from-chart", "notes-app.mmd"], `Usage: ${FROM_CHART_USAGE}`], + "chart-to-tla.mjs": [[], "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"], + "tlc-to-chart.mjs": [[], "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"], + "compare-charts.mjs": [[], "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"], + }; + for (const [name, [before, usage]] of Object.entries(help)) { + const script = name.split(" ")[0]; + for (const flag of ["-h", "--help"]) { + const r = node(script, [...before, flag], here); + assert.equal(r.status, 0, `${name} ${flag}`); + assert.equal(r.stderr, ""); + assert.equal(r.stdout.split("\n")[0], usage); + assert.match(r.stdout, /\nExit codes\n {2}0 .*\n(.*\n)* {2}1 .*\n(.*\n)* {2}2 /); + } + } + const top = node("statechart.mjs", ["--help"], here).stdout.split("\n"); + assert.equal(top[1], ` ${FROM_CHART_USAGE}`); + assert.equal(top[2], " node statechart.mjs [from-spec|from-chart] --help"); + for (const flag of [ + "--fixed FIXED.tla", + "--label TEXT", + "--map FILE", + "--module NAME", + "--title TEXT", + "--out DIR", + "--png", + "-h, --help", + ]) + assert.ok( + top.some((l) => l.startsWith(` ${flag} `)), + flag, + ); + for (const [script, args, stderr] of [ + [ + "statechart.mjs", + ["from-spec", "--nope"], + "unknown argument --nope. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + "chart-to-tla.mjs", + ["--nope"], + "unknown argument --nope. Run node chart-to-tla.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--nope"], + "unknown argument --nope. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--regions"], + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--proof", "p.mmd"], + "--proof writes the regions proof; it needs --regions. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "compare-charts.mjs", + ["--nope"], + "unknown argument --nope. Run node compare-charts.mjs --help for the flags.\n", + ], + ]) { + const r = node(script, args, here); + assert.equal(r.status, 2, script); + assert.equal(r.stdout, ""); + assert.equal(r.stderr, stderr); + } +}); + +const tools = (() => { + try { + return findTlc(); + } catch { + return null; + } +})(); +const needsTlc = { skip: tools ? false : "cannot find TLC" }; + +// Check every INVARIANT in NAME.cfg in one run and dump NAME.dot. +function tlc(dir, name) { + const r = spawnSync( + tools.java, + [ + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + mkdtempSync(join(tmpdir(), "tlc-")), + "-deadlock", + "-continue", + "-dump", + "dot,actionlabels", + `${name}.dot`, + "-config", + `${name}.cfg`, + `${name}.tla`, + ], + { cwd: dir, encoding: "utf8" }, + ); + writeFileSync(join(dir, `${name}.out`), r.stdout); + return r; } -test("chart to TLA+ to chart gives back the same chart", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); - assert.equal(node("chart-to-tla.mjs", ["upload.mmd", "--module", "Upload"], dir).status, 0); - assert.match(tlc(dir, "Upload").stdout, /Model checking completed\. No error has been found\./); - const typeOk = tlc(dir, "Upload", "TypeOK"); - assert.equal(typeOk.status, 0, typeOk.stdout); - assert.doesNotMatch(typeOk.stdout, /^Error:/m); - const drawn = node("tlc-to-chart.mjs", ["--dot", "Upload.dot", "--map", "Upload.map.mjs", "--highlight", "0", "--out", "again.mmd"], dir); - assert.equal(drawn.status, 0, drawn.stderr); - const same = node("compare-charts.mjs", ["upload.mmd", "again.mmd"], dir); - assert.equal(same.stdout.trim(), "same"); +test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); + const r = node("statechart.mjs", ["from-chart", "upload.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.equal( + r.stdout, + `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd. +TLC on Upload.tla: + TypeOK holds. + +Chart: 5 states and 8 transitions from 5 model states. +5 work, 0 break a rule. + +No rule is broken. + +The spec draws upload.mmd back unchanged. + +Wrote chart.mmd, stories.md, summary.json in Upload-statechart. +`, + ); + assert.equal(r.stderr, ""); +}); + +test("TLC reads a label with a guarded and an unguarded move in one region", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("statechart.mjs", ["from-chart", "switch.mmd"], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws switch\.mmd back unchanged\.\n/); }); -test("a never line finds the design bug, and the fixed model marks it fixed", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); - assert.equal(node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir).status, 0); - const rule = "NeverSyncedWhileOffline"; - tlc(dir, "Sync"); - // With -continue, TLC exits 0 and still prints "Model checking completed" after a violation. - const broken = tlc(dir, "Sync", rule); - assert.equal(broken.status, 0); - assert.match(broken.stdout, /Invariant NeverSyncedWhileOffline is violated[^]*Model checking completed\. No error has been found\./); - - const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); - const guarded = spec.replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n').replace("MODULE Sync", "MODULE SyncFixed"); - assert.notEqual(guarded, spec); - writeFileSync(join(dir, "SyncFixed.tla"), guarded); - writeFileSync(join(dir, "SyncFixed.cfg"), readFileSync(join(dir, "Sync.cfg"))); - tlc(dir, "SyncFixed"); - assert.doesNotMatch(tlc(dir, "SyncFixed", rule).stdout, /is violated/); - - const args = ["--dot", "Sync.dot", "--map", "Sync.map.mjs", "--traces", `Sync-${rule}.out`, "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"]; - assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); - const before = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(before.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 2]]); - assert.equal(before.chartStatus.wrong, 1); - assert.match(readFileSync(join(dir, "story.md"), "utf8"), /1\. the network drops\. Now: Offline \| Waiting to sync\n2\. the server confirms\. Now: Offline \| Synced/); - assert.match(readFileSync(join(dir, "chart.mmd"), "utf8"), /: 2\. the server confirms\n/); - - const withFix = [...args, "--fixed-dot", "SyncFixed.dot", "--fixed-traces", `SyncFixed-${rule}.out`]; - assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); - const after = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(after.chartStatus, { works: 3, wrong: 0, fixed: 1 }); - assert.equal(after.classes[0].fix, "Offline | Waiting to sync"); - assert.match(readFileSync(join(dir, "story.md"), "utf8"), /`TheServerConfirms`\) cannot happen/); +const syncStory = `## 1. Offline | Synced + +Rule broken: You never see "Synced" while you are offline. +This is the only way to get here in the model. + +0. Start: Online | Synced +1. The network drops. Now: Offline | Waiting to sync +2. The server confirms. Now: Offline | Synced +`; + +test("from-chart finds the design bug, and from-spec with the fix marks it fixed", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const today = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(today.status, 1, today.stderr); + assert.equal( + today.stdout, + `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. + +Chart: 4 states and 8 transitions from 4 model states. +3 work, 1 breaks a rule. + +${syncStory} +The spec draws sync.mmd back unchanged. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Sync-statechart", "bug-1.mmd"), "utf8"), + /: 2\. the server confirms\n/, + ); + + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + const guarded = spec + .replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n') + .replace("MODULE Sync", "MODULE SyncFixed"); + assert.notEqual(guarded, spec); + writeFileSync(join(dir, "SyncFixed.tla"), guarded); + const fixed = node("statechart.mjs", ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], dir); + assert.equal(fixed.status, 1, fixed.stderr); + assert.equal( + fixed.stdout, + `TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. +TLC on SyncFixed.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 4 states and 9 transitions from 4 model states. +3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. + +${syncStory} +With the fix, step 2 cannot happen, so you stay at: Offline | Waiting to sync. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + const summary = JSON.parse(readFileSync(join(dir, "Sync-statechart", "summary.json"), "utf8")); + assert.deepEqual(summary.chartStatus, { works: 3, wrong: 0, fixed: 1 }); + assert.equal(summary.classes[0].fix, "Offline | Waiting to sync"); + + const map = readFileSync(join(dir, "Sync.map.mjs"), "utf8"); + writeFileSync(join(dir, "Sync.map.mjs"), `${map}export const group = () => "Your notes";\n`); + const grouped = node( + "statechart.mjs", + ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], + dir, + ); + assert.equal(grouped.status, 1, grouped.stderr); + const lines = readFileSync(join(dir, "Sync-statechart", "chart.mmd"), "utf8").split("\n"); + assert.deepEqual( + lines.filter((l) => /^ {2}(classDef|class) |legend/.test(l)), + [ + " classDef plainFixed fill:#f4f6f7,stroke:#1e8449,stroke-width:4px,color:#145a32", + " classDef group0 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group0Fixed fill:#d6eaf8,stroke:#1e8449,stroke-width:4px,color:#145a32", + ' state "Legend" as legend {', + ' state "Your notes" as legendGroup0', + ' state "Fixed or new with the fix" as legendFixed', + " class legendGroup0 group0", + " class legendFixed plainFixed", + " class s0 group0Fixed", + " class s1 group0", + " class s2 group0", + " class s3 group0", + ], + ); + writeFileSync(join(dir, "Sync.map.mjs"), map); + + writeFileSync(join(dir, "Sync.tla"), guarded.replace("MODULE SyncFixed", "MODULE Sync")); + const coded = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(coded.status, 1, coded.stderr); + assert.equal( + coded.stdout, + `Kept Sync.tla. Delete it to write the spec again from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 3 states and 5 transitions from 3 model states. +3 work, 0 break a rule. + +No rule is broken. + +The spec does not draw sync.mmd: + states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them): + Offline | Synced + transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step): + Offline | Synced --> Offline | Waiting to sync : the network drops + Offline | Synced --> Offline | Waiting to sync : you edit a note + Offline | Synced --> Online | Synced : the network returns + Offline | Waiting to sync --> Offline | Synced : the server confirms + 5 difference(s) + +Wrote chart.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); }); const pay = (name, charge) => `---- MODULE ${name} ---- @@ -414,57 +1001,177 @@ export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you export const cause = () => "each charge request charges again"; `; -test("a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; - writeFileSync(join(dir, "Pay.tla"), pay("Pay", `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`)); - writeFileSync( - join(dir, "PayFixed.tla"), - pay("PayFixed", `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`), - ); - writeFileSync(join(dir, "Pay.cfg"), cfg); - writeFileSync(join(dir, "PayFixed.cfg"), cfg); - writeFileSync(join(dir, "pay.map.mjs"), payMap); - assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); - assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); - - const statusOf = (chart) => { - const names = new Map([...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [m[2], m[1].replace(//g, " ")])); - return Object.fromEntries([...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]])); - }; - const args = ["--dot", "Pay.dot", "--map", "pay.map.mjs", "--traces", "Pay.out", "--highlight", "0", "--out", "chart.mmd"]; - assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); - assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { - Cart: "works", - Paying: "works", - "Paying, charged": "works", - "Paying, charged twice": "wrong", - "Refunded, charged": "wrong", - }); - - const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; - assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); - const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); - assert.deepEqual(statusOf(chart), { - Cart: "works", - "Paid, charged": "fixed", - Paying: "works", - "Paying, charged": "works", - "Paying, charged twice": "fixed", - "Refunded, charged": "fixed", - }); - const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => t.label.endsWith("with the fix")); - assert.deepEqual( - fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), - ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], - ); -}); +test( + "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "PayFixed.tla"), + pay( + "PayFixed", + `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`, + ), + ); + writeFileSync(join(dir, "Pay.cfg"), cfg); + writeFileSync(join(dir, "PayFixed.cfg"), cfg); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); + + const statusOf = (chart) => { + const names = new Map( + [...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [ + m[2], + m[1].replace(//g, " "), + ]), + ); + return Object.fromEntries( + [...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]]), + ); + }; + const args = [ + "--dot", + "Pay.dot", + "--map", + "pay.map.mjs", + "--traces", + "Pay.out", + "--highlight", + "0", + "--out", + "chart.mmd", + ]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { + Cart: "works", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "wrong", + "Refunded, charged": "wrong", + }); + + const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); + assert.deepEqual(statusOf(chart), { + Cart: "works", + "Paid, charged": "fixed", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "fixed", + "Refunded, charged": "fixed", + }); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works today", "Fixed or new with the fix"], + ); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/); + const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => + t.label.endsWith("with the fix"), + ); + assert.deepEqual( + fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), + ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], + ); + }, +); + +test( + "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n", + ); + const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(first.status, 2); + assert.equal(first.stdout, "TLC on Pay.tla:\n NoDoubleCharge is broken.\n"); + assert.equal( + first.stderr, + "\nWrote Pay.map.mjs. Fill in its labels, then run this command again.\n", + ); + const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8"); + assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m); + assert.match( + scaffold, + /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), + "SPECIFICATION Spec\n", + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n", + ); + + writeFileSync( + join(dir, "Pay.map.mjs"), + `${payMap}export const detail = (v) => \`charges = \${v.charges}\`;\n`, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + assert.equal( + r.stdout, + `TLC on Pay.tla: + NoDoubleCharge is broken. + +Chart: 5 states and 4 transitions from 5 model states. +3 work, 2 break a rule. + +## 1. Paying, charged twice + +Rule broken: \`NoDoubleCharge\` +Why: each charge request charges again. +This is the only way to get here in the model. + +0. Start: Cart +1. You pay. Now: Paying +2. The bank charges you. Now: Paying, charged +3. The bank charges you. Now: Paying, charged twice + +Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, summary.json in Pay-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Pay-statechart", "bug-1-layered.mmd"), "utf8"), + /state "Paying, charged twice" as g\d \{\n {4}state "charges = 2" as s\d\n/, + ); + assert.deepEqual(readdirSync(join(dir, "Pay-statechart")).sort(), [ + "bug-1-developer.mmd", + "bug-1-layered.mmd", + "bug-1.mmd", + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); + }, +); test("a rule broken by a start state marks that state wrong", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync( - join(dir, "Start.tla"), - `---- MODULE Start ---- + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Start.tla"), + `---- MODULE Start ---- VARIABLES conn, badge vars == <> Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced" @@ -474,16 +1181,33 @@ Spec == Init /\\ [][Next]_vars NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced") ==== `, - ); - writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); - writeFileSync(join(dir, "start.map.mjs"), 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n'); - assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); - const args = ["--dot", "Start.dot", "--map", "start.map.mjs", "--traces", "Start.out", "--summary", "summary.json", "--out", "chart.mmd"]; - const r = node("tlc-to-chart.mjs", args, dir); - assert.equal(r.status, 0, r.stderr); - const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(summary.classes.map((c) => [c.end, c.readerSteps]), [["Offline | Synced", 0]]); - assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); + ); + writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); + writeFileSync( + join(dir, "start.map.mjs"), + 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n', + ); + assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); + const args = [ + "--dot", + "Start.dot", + "--map", + "start.map.mjs", + "--traces", + "Start.out", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ]; + const r = node("tlc-to-chart.mjs", args, dir); + assert.equal(r.status, 0, r.stderr); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.end, c.readerSteps]), + [["Offline | Synced", 0]], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); }); const edit = (name, fixed) => `---- MODULE ${name} ---- @@ -500,30 +1224,627 @@ NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed) ==== `; -test("a bug that only the fixed model has is numbered after the current bugs and drawn wrong", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; - for (const [name, fixed] of [["Edit", false], ["EditFixed", true]]) { - writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); - writeFileSync(join(dir, `${name}.cfg`), cfg); - assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); - } - writeFileSync( - join(dir, "edit.map.mjs"), - 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', - ); - const args = ["--dot", "Edit.dot", "--map", "edit.map.mjs", "--traces", "Edit.out", "--fixed-dot", "EditFixed.dot", "--fixed-traces", "EditFixed.out"]; - const r = node("tlc-to-chart.mjs", [...args, "--cover", "--highlight", "0", "--story", "story.md", "--summary", "summary.json", "--out", "chart.mmd"], dir); - assert.equal(r.status, 0, r.stderr); - assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); - const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); - assert.deepEqual(summary.classes.map((c) => [c.n, c.end, c.fix]), [ - [1, "Lost edit", "Saving"], - [2, "Flash", "new with the fix"], - ]); - assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); - assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); - const two = node("tlc-to-chart.mjs", [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], dir); - assert.equal(two.status, 0, two.stderr); - assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); -}); +test( + "a bug that only the fixed model has is numbered after the current bugs and drawn wrong", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; + for (const [name, fixed] of [ + ["Edit", false], + ["EditFixed", true], + ]) { + writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); + writeFileSync(join(dir, `${name}.cfg`), cfg); + assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); + } + writeFileSync( + join(dir, "edit.map.mjs"), + 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', + ); + const args = [ + "--dot", + "Edit.dot", + "--map", + "edit.map.mjs", + "--traces", + "Edit.out", + "--fixed-dot", + "EditFixed.dot", + "--fixed-traces", + "EditFixed.out", + ]; + const r = node( + "tlc-to-chart.mjs", + [ + ...args, + "--cover", + "--highlight", + "0", + "--story", + "story.md", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.n, c.end, c.fix]), + [ + [1, "Lost edit", "Saving"], + [2, "Flash", "new with the fix"], + ], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); + const two = node( + "tlc-to-chart.mjs", + [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], + dir, + ); + assert.equal(two.status, 0, two.stderr); + assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); + }, +); + +// Each edge as "from --> to : label" with full state labels, and an arrow from a box's border +// expanded to one edge from each state in the box. +function chartEdges(mmd) { + const names = new Map(); + const members = new Map(); + let box = null; + for (const line of mmd.split("\n")) { + const opens = /^ {2}state "([^"]+)" as (g\d+) \{$/.exec(line); + const state = /^ +state "([^"]+)" as (s\d+)$/.exec(line); + const said = (s) => s.replaceAll("
", " "); + if (opens) { + box = { id: opens[2], name: said(opens[1]) }; + members.set(box.id, []); + } else if (line === " }") box = null; + else if (state) { + names.set(state[2], box ? `${box.name}: ${said(state[1])}` : said(state[1])); + if (box) members.get(box.id).push(state[2]); + } + } + return [...mmd.matchAll(/^ {2}([gs]\d+) --> (s\d+) : (.*)$/gm)] + .flatMap(([, from, to, label]) => + (members.get(from) ?? [from]).map((f) => `${names.get(f)} --> ${names.get(to)} : ${label}`), + ) + .sort(); +} + +test( + "states of one context sit in one box, and an arrow from the box's border stands for the same step from each", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Window.tla"), + `---- MODULE Window ---- +VARIABLES where, reply +vars == <> +Init == where = "Starting" /\\ reply = "none" +Boot == where = "Starting" /\\ where' = "Chat open" /\\ UNCHANGED reply +Ask == where = "Chat open" /\\ reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Close == where = "Chat open" /\\ where' = "Window closed" /\\ UNCHANGED reply +Open == where = "Window closed" /\\ where' = "Chat open" /\\ reply' = "none" +Next == Boot \\/ Ask \\/ Answer \\/ Close \\/ Open +Spec == Init /\\ [][Next]_vars +NoReplyWhileClosed == ~(where = "Window closed" /\\ reply = "shown") +==== +`, + ); + writeFileSync(join(dir, "Window.cfg"), "SPECIFICATION Spec\nINVARIANT NoReplyWhileClosed\n"); + writeFileSync( + join(dir, "window.map.mjs"), + `const R = { none: "no question", pending: "reply on its way", shown: "reply shown" }; +export const state = (v) => \`\${v.where}: \${v.where === "Starting" ? "loading" : R[v.reply]}\`; +export const step = (a) => ({ Boot: "the app starts", Ask: "you ask", Answer: "the reply arrives", Close: "you close the window", Open: "you open the window" })[a]; +`, + ); + assert.match(tlc(dir, "Window").stdout, /Invariant NoReplyWhileClosed is violated/); + const args = ["--dot", "Window.dot", "--map", "window.map.mjs", "--traces", "Window.out"]; + const draw = (extra, out) => { + const r = node("tlc-to-chart.mjs", [...args, ...extra, "--out", out], dir); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, out), "utf8"); + }; + const boxed = draw([], "boxed.mmd"); + const flat = draw(["--flat"], "flat.mmd"); + + assert.match( + boxed, + /^ {2}state "Chat open" as g0 \{\n {4}state "no question" as s0\n {4}state "reply on its way" as s1\n {4}state "reply shown" as s2\n {2}\}\n {2}state "Window closed" as g1 \{\n {4}state "no question" as s3\n {4}state "reply on its way" as s4\n {4}state "reply shown" as s5\n {2}\}\n {2}state "Starting: loading" as s6\n/m, + ); + assert.deepEqual( + boxed.split("\n").filter((l) => /you open the window/.test(l)), + [" g1 --> s0 : you open the window"], + ); + assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3); + assert.doesNotMatch(flat, /as g\d+ \{/); + assert.deepEqual(chartEdges(boxed), chartEdges(flat)); + assert.match(boxed, /^ {2}class s5 wrongPath$/m); + assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m); + assert.match(boxed, /state "Breaks a rule" as legendWrong/); + }, +); + +const save = (name, code) => `---- MODULE ${name} ---- +VARIABLES screen, note${code ? ", retries" : ""} +vars == <> +Init == screen = "Editing" /\\ note = [text |-> "draft"${code ? `, rev |-> 0] /\\ retries = 0` : "]"} +Save == screen = "Editing" /\\ screen' = "Saving" /\\ UNCHANGED <> +Confirm == screen = "Saving" /\\ screen' = "Saved" /\\ note' = [text |-> "stored"${code ? `, rev |-> 1] /\\ UNCHANGED retries` : "]"} +Edit == screen = "Saved" /\\ screen' = "Editing" /\\ note' = [note EXCEPT !.text = "draft"]${code ? " /\\ retries' = 0" : ""} +${ + code + ? `Retry == screen = "Saving" /\\ retries = 0 /\\ retries' = 1 /\\ UNCHANGED <> +TimeOut == screen = "Saving" /\\ retries = 1 /\\ screen' = "Saved" /\\ UNCHANGED <> +Next == Save \\/ Retry \\/ Confirm \\/ TimeOut \\/ Edit` + : "Next == Save \\/ Confirm \\/ Edit" +} +Spec == Init /\\ [][Next]_vars +TypeOK == screen \\in {"Editing", "Saving", "Saved"} +\\* You never see "Saved" while the note is still a draft. +NoFalseSaved == ~(screen = "Saved" /\\ note.text = "draft") +==== +`; + +test( + "from-spec --against compares the code with the spec on the parts both have, and --expect-broken sets the exit code", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "SaveCode.tla"), save("SaveCode", true)); + writeFileSync(join(dir, "SaveAgreed.tla"), save("SaveAgreed", false)); + writeFileSync( + join(dir, "SaveCode.cfg"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\n", + ); + writeFileSync( + join(dir, "SaveCode.map.mjs"), + `export const readerActions = ["Save", "Edit"]; +export const state = (v) => \`\${v.screen}, \${v.note.text}\`; +export const step = (a) => ({ Save: "you press Save", Retry: "the app tries again", Confirm: "the server stores the note", TimeOut: "the save times out", Edit: "you edit the note" })[a] ?? ""; +export const cause = () => "the app stops after one retry and shows Saved anyway"; +`, + ); + const run = (...extra) => + node( + "statechart.mjs", + [ + "from-spec", + "SaveCode.tla", + "--against", + "SaveAgreed.tla", + "--label", + "the agreed chart", + ...extra, + ], + dir, + ); + + const unknown = run("--expect-broken", "NoLostNote"); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "--expect-broken NoLostNote: not a rule in SaveCode.cfg, whose rules are TypeOK, NoFalseSaved.\n", + ); + + const expected = run("--expect-broken", "NoFalseSaved"); + assert.equal(expected.status, 0, expected.stderr); + assert.equal( + expected.stdout, + `TLC on SaveCode.tla: + TypeOK holds. + NoFalseSaved is broken (expected). +TLC on SaveAgreed.tla: + TypeOK holds. + NoFalseSaved holds. + +Chart: 4 states and 4 transitions from 10 model states. +3 work, 1 breaks a rule, 0 only in the agreed chart. + +## 1. Saved, draft + +Rule broken: You never see "Saved" while the note is still a draft. +Why: the app stops after one retry and shows Saved anyway. +There are 2 ways to get here in the model. This is the shortest. + +0. Start: Editing, draft +1. You press Save. Now: Saving, draft +2. The save times out. Now: Saved, draft + +In the agreed chart, step 2 cannot happen, so you stay at: Saving, draft. + +Broken as expected: NoFalseSaved. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. +`, + ); + const out = join(dir, "SaveCode-statechart"); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works", "Breaks a rule"], + ); + assert.match(chart, /^ {2}s3 --> s3 : the save times out, not in theagreed chart$/m); + const bug = readFileSync(join(out, "bug-1.mmd"), "utf8"); + assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [ + "Editing, draft", + "Saved, draft", + "Saving, draft", + ]); + const summary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8")); + assert.deepEqual(summary.second.role, "against"); + assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]); + assert.equal(summary.classes[0].against, "Saving, draft"); + assert.deepEqual(summary.codeLike, []); + + const unexpected = run(); + assert.equal(unexpected.status, 1, unexpected.stderr); + const holds = run("--expect-broken", "TypeOK"); + assert.equal(holds.status, 1, holds.stderr); + assert.match( + holds.stdout, + /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/, + ); + + writeFileSync( + join(dir, "code.map.mjs"), + readFileSync(join(out, "../SaveCode.map.mjs"), "utf8").replace( + '"the app stops after one retry and shows Saved anyway"', + '"retryCount hit maxRetries"', + ), + ); + const coded = run("--map", "code.map.mjs", "--expect-broken", "NoFalseSaved"); + assert.equal(coded.status, 0, coded.stderr); + assert.match( + coded.stdout, + /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/, + ); + }, +); + +test( + "from-chart checks a chart whose regions guard each other, and draws it back unchanged", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + const r = node( + "statechart.mjs", + ["from-chart", "lamp.mmd", "--expect-broken", "LitWhileOff"], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws lamp\.mmd back unchanged\.\n/); + }, +); + +const doorSpec = `---- MODULE Door ---- +VARIABLES where, reply +vars == <> +Init == where = "Open" /\\ reply = "none" +Ask == reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == where = "Open" /\\ reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Drop == where = "Closed" /\\ reply = "pending" /\\ reply' = "lost" /\\ UNCHANGED where +Close == where = "Open" /\\ where' = "Closed" /\\ UNCHANGED reply +Reopen == where = "Closed" /\\ where' = "Open" /\\ (reply' = reply \\/ (reply = "pending" /\\ reply' = "shown")) +Next == Ask \\/ Answer \\/ Drop \\/ Close \\/ Reopen +Spec == Init /\\ [][Next]_vars +NoLostReply == reply # "lost" +NoShownWhileClosed == ~(where = "Closed" /\\ reply = "shown") +==== +`; + +const doorMap = `const W = { Open: "Window open", Closed: "Window closed" }; +const R = { none: "no question", pending: "reply on its way", shown: "reply shown", lost: "reply lost" }; +export const regionNames = ["Where you are", "Your reply"]; +export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`; +export const step = (a) => ({ Ask: "you ask", Answer: "the reply arrives", Drop: "the app drops the reply", Close: "you close the window", Reopen: "you open the window" })[a]; +`; + +// Checks Door.tla with one rule, which breaks, and returns what from-spec printed and drew. +function runDoor(map) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.map.mjs"), map); + const out = join(dir, "Door-statechart"); + const read = (f) => readFileSync(join(out, f), "utf8").replaceAll("
", " "); + return (rule) => { + writeFileSync(join(dir, "Door.cfg"), `SPECIFICATION Spec\nINVARIANT ${rule}\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla", "--expect-broken", rule], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + const every = join(out, "tlc", "regions-all.mmd"); + return { + stdout: r.stdout, + chart: read("chart.mmd"), + every: existsSync(every) ? read("tlc/regions-all.mmd") : null, + bug: read("bug-1.mmd"), + out, + }; + }; +} + +test( + "from-spec draws parallel regions only when they combine back into TLC's graph, and boxes otherwise", + needsTlc, + () => { + const run = runDoor(doorMap); + const proved = run("NoLostReply"); + assert.match( + proved.stdout, + /\nChart: 6 states and 5 transitions from 8 model states\.\nIt has 2 parallel regions, of 2 and 4 states\. Proof: their 7 arrows together make exactly the 8 states and 13 labeled steps of Door-statechart\/tlc\/regions-proof\.mmd, and the 5 drawn arrows make only steps among them\. Door-statechart\/tlc\/regions-all\.mmd has all 7 arrows\.\n5 work, 1 breaks a rule\.\n/, + ); + const lines = proved.chart.split("\n"); + assert.ok(lines.includes(' state "Where you are" as region0 {')); + assert.ok(lines.includes(" --")); + const guarded = lines.findIndex((l) => /: the reply arrives \[when Window open\]$/.test(l)); + assert.match(lines[guarded + 1], /^ {6}%% when r0s\d$/); + assert.doesNotMatch(proved.chart, /^ {6}r1s\d --> r1s\d : you open the window/m); + assert.equal( + proved.every + .split("\n") + .filter((l) => /^ {6}(r1s\d) --> \1 : you open the window \[when Window closed\]$/.test(l)) + .length, + 1, + ); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const boxed = run("NoShownWhileClosed"); + assert.equal(boxed.every, null); + assert.match( + boxed.stdout, + /\nIt has no parallel regions, because "Window closed: reply shown" is "Breaks a rule", and its two region states would show "Works"\.\n/, + ); + assert.doesNotMatch(boxed.chart, /^ +--$/m); + assert.match(boxed.chart, /^ {2}state "Window closed" as g\d \{$/m); + }, +); + +test( + "the mapping's guard words show on an arrow only when every model step behind it has them", + needsTlc, + () => { + const run = runDoor( + `${doorMap}export const guard = (a, before) => + ({ Answer: "window open", Drop: "window closed", Ask: before.where === "Open" ? "window open" : "" })[a] ?? ""; +`, + ); + const proved = run("NoLostReply"); + const lines = proved.chart.split("\n"); + for (const words of [ + /: the reply arrives \[window open\]$/, + /: the app drops the reply \[window closed\]$/, + ]) + assert.match(lines[lines.findIndex((l) => words.test(l)) + 1], /^ {6}%% when r0s\d$/); + assert.ok(lines.some((l) => /^ {6}r1s\d --> r1s\d : you ask$/.test(l))); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + assert.match(proved.bug, / : \d\. the app drops the reply \[window closed\]$/m); + + const boxed = run("NoShownWhileClosed"); + assert.match(boxed.chart, / : you ask \[window open\]$/m); + assert.match(boxed.chart, / : you ask$/m); + }, +); + +test( + "the mapping's timer() writes after N s on timer steps, and refuses a value that is not seconds", + needsTlc, + () => { + const timed = runDoor( + `${doorMap}export const timer = (a) => (a === "Drop" ? 30 : undefined);\n`, + )("NoLostReply"); + assert.match(timed.chart, / : after 30 s, the app drops the reply \[when Window closed\]$/m); + assert.match(timed.bug, / : \d\. after 30 s, the app drops the reply$/m); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + timed.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.cfg"), "SPECIFICATION Spec\nINVARIANT NoLostReply\n"); + writeFileSync(join(dir, "Door.map.mjs"), `${doorMap}export const timer = () => "soon";\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla"], dir); + assert.notEqual(r.status, 0); + assert.match( + r.stderr, + /timer\(\) must return a number of seconds above 0, or nothing; got "soon" for \w+/, + ); + }, +); + +const groupMap = `${doorMap}export const group = (v) => + ({ none: "Nothing to show", pending: "Waiting", shown: "Reply shown", lost: "Reply shown" })[v.reply]; +export const groupColors = { "Reply shown": "#d1f2eb", Waiting: null, "Never drawn": "#ffffff" }; +`; + +test( + "group() fills each state with its group's color, and the legend lists only the groups the chart draws", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const lines = run.chart.split("\n"); + assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [ + "stateDiagram-v2", + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group0 fill:#d1f2eb,stroke:#697976,color:#1b2631", + " classDef group0Wrong fill:#d1f2eb,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group1 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group2 fill:#fdebd0,stroke:#7f7668,color:#1b2631", + ' state "Legend" as legend {', + ' state "Reply shown" as legendGroup0', + ' state "Waiting" as legendGroup1', + ' state "Nothing to show" as legendGroup2', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendGroup1 group1", + " class legendGroup2 group2", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(' state "reply lost" as r1s1')); + assert.deepEqual( + lines.filter((l) => l.startsWith(" class r")), + [ + " class r0s0 plain", + " class r0s1 plain", + " class r1s0 group2", + " class r1s1 group0Wrong", + " class r1s2 group1", + " class r1s3 group0", + ], + ); + assert.match(run.stdout, /\n5 work, 1 breaks a rule\.\n/); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + run.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const bug = run.bug.split("\n"); + assert.ok( + bug.includes( + " classDef group1Path fill:#d6eaf8,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + ), + ); + assert.deepEqual( + bug.filter((l) => /^ {2}class s\d/.test(l)), + [ + " class s0 group0Wrong", + " class s1 group1Path", + " class s2 group2Path", + " class s3 group1Path", + ], + ); + + const summary = JSON.parse(readFileSync(join(run.out, "summary.json"), "utf8")); + assert.deepEqual(summary.chartGroups, [ + { name: "Reply shown", color: "#d1f2eb", states: 2 }, + { name: "Waiting", color: "#d6eaf8", states: 1 }, + { name: "Nothing to show", color: "#fdebd0", states: 1 }, + ]); + assert.deepEqual(summary.mixedGroups, []); + }, +); + +test( + "the mapping cannot hide a broken rule, and refuses a status name, a dark or bad color, or a group that is not a name", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const tlcDir = join(run.out, "tlc"); + const chart = (map, ...extra) => { + writeFileSync(join(tlcDir, "probe.map.mjs"), map); + return node( + "tlc-to-chart.mjs", + [ + "--dot", + "Door.dot", + "--map", + "probe.map.mjs", + "--traces", + "Door-NoLostReply.out", + "--highlight", + "0", + ...extra, + ], + tlcDir, + ); + }; + + const hidden = chart( + `${doorMap}export const group = () => "All good";\nexport const groupColors = { "All good": "#e9f7ef" };\n`, + "--regions", + ); + assert.equal(hidden.status, 0, hidden.stderr); + const lines = hidden.stdout.split("\n"); + assert.ok( + lines.includes( + " classDef group0Wrong fill:#e9f7ef,stroke:#c0392b,stroke-width:4px,color:#7b241c", + ), + ); + const legend = lines.indexOf(' state "Legend" as legend {'); + assert.deepEqual(lines.slice(legend, legend + 6), [ + ' state "Legend" as legend {', + ' state "All good" as legendGroup0', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(" class r1s1 group0Wrong")); + assert.equal(lines.filter((l) => / group0Wrong$/.test(l)).length, 1); + + const coarse = chart( + `const W = { Open: "Window open", Closed: "Window closed" }; +export const state = (v) => W[v.where]; +export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); +`, + "--summary", + "coarse.json", + ); + assert.equal(coarse.status, 0, coarse.stderr); + assert.deepEqual( + coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)), + [ + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " class legendWrong plainWrong", + " class s0 plainWrong", + " class s1 plainWrong", + ], + ); + const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8")); + assert.deepEqual(summary.chartGroups, []); + assert.deepEqual(summary.mixedGroups, ["Window closed", "Window open"]); + + for (const [map, error] of [ + [ + `export const group = (v) => (v.reply === "lost" ? "Breaks a rule" : "Fine");`, + /group "Breaks a rule" has the name of a status; TLC decides the status, so name the group for what the reader sees/, + ], + [`export const group = () => "fixed";`, /group "fixed" has the name of a status/], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "#123" };`, + /groupColors\["All good"\] "#123" is too dark for the status borders to show on it/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "red" };`, + /groupColors\["All good"\] "red" is not "#rgb" or "#rrggbb"/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = ["#ffffff"];`, + /groupColors must be an object from a group name to a color such as #d6eaf8/, + ], + [`export const group = () => 1;`, /group\(\) must return a group name or nothing; got 1/], + ]) { + const r = chart(`${doorMap}${map}\n`, "--regions"); + assert.equal(r.status, 1, map); + assert.equal(r.stdout, "", map); + assert.match(r.stderr, error); + } + }, +); diff --git a/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/pstack/skills/statechart/scripts/tlc-to-chart.mjs index 0a3ffdb42..d5a3f4c33 100644 --- a/pstack/skills/statechart/scripts/tlc-to-chart.mjs +++ b/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -1,451 +1,1309 @@ #!/usr/bin/env node -// Collapse a TLC state graph onto the reader's states and write a Mermaid stateDiagram-v2. -// -// node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [options] -// -// --dot FILE state graph from TLC -dump dot,actionlabels FILE (the current code) -// --map FILE mapping module: state(vars, facts), optional detail(vars, facts), -// step(action, before, after), cause(vars, facts, invariant) (the hidden reason a bad state is bad; -// counterexamples with the same invariant and cause are one bug), and -// readerActions (the actions the reader takes). -// facts.enabled is the set of actions the state enables; facts.settled is true when -// only reader actions are enabled, so nothing else happens until the reader acts. -// --traces FILE TLC output that holds counterexamples (repeat; -continue gives one per bad state). -// A file from a TLC run that stopped early or hit an error other than a broken rule is refused. -// --fixed-dot FILE state graph of the model with the open fix; counterexamples replay against it -// --fixed-traces F TLC output for the same invariants on the fixed model (repeat); marks states fixed or still -// wrong, and adds the bugs that only the fixed model has after the current model's bugs -// --view NAME customer (state labels), developer (detail labels), or layered (detail inside state) -// --cover keep only the shortest path to each settled reader state, plus counterexample paths -// --highlight N draw counterexample class N as a numbered path (default 1; 0 for none) -// --path-only draw only the highlighted path -// --all-labels write every label on an edge (default: the two most common, then " or …") -// --title TEXT chart title -// --out FILE Mermaid output (default stdout) -// --story FILE reader stories, one per counterexample class -// --summary FILE JSON counts for the chart and the classes import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { LEGEND_ID, checkTlcOutput, parseDot, parseTraces, quote, stateKey, wrap, STATUS_CLASSES } from "./lib.mjs"; +import { + GROUP_PALETTE, + LEGEND_ID, + PLAIN_FILL, + checkTlcOutput, + compareCharts, + groupClassDef, + groupColorProblem, + isRecord, + parseDot, + parseMermaid, + parseTraces, + productOf, + quote, + ruleSentences, + stateKey, + wrap, + STATUS_CLASSES, +} from "./lib.mjs"; -const args = { traces: [], fixedTraces: [], view: "customer", highlight: 1 }; +const USAGE = "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"; +const HELP = `${USAGE} + +Collapses a TLC state graph onto the reader's states and writes a Mermaid stateDiagram-v2. +statechart.mjs runs it once for the overview and once for each bug chart. + + --dot FILE The state graph from TLC -dump dot,actionlabels FILE, for the current code. + Required. + --map FILE The mapping module. Required. It exports state(vars, facts), and + optionally detail(vars, facts), step(action, before, after), + guard(action, before, after), timer(action, before, after), + cause(vars, facts, invariant), group(vars, facts), groupColors, + regionNames, and readerActions. guard is when the step + can happen, in plain words. An edge reads "step [words]" when every + model step behind it has words. timer is how many seconds the code waits + before a timer step, and the step reads "after N s, step". + cause is the hidden reason a bad state is bad: counterexamples with the + same invariant and cause are one bug. readerActions are the actions the + reader takes. group is the group of a state, below. + facts.enabled is the set of actions the state enables. facts.settled is + true when only reader actions are enabled, so nothing else happens until + the reader acts. + --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one + per bad state. It refuses a file from a TLC run that stopped early or hit + an error other than a broken rule. + --fixed-dot FILE The state graph of the model with the open fix. Counterexamples replay on + it. + --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks + states fixed or still wrong, and adds the bugs that only the fixed model + has after the current model's bugs. + --against-dot FILE The state graph of the spec that the current code must follow. + Counterexamples replay on it. Do not use it with --fixed-dot. + --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the + bugs that only the spec has number after the code's bugs. + --label TEXT The name of the second model in the chart and the stories. Default: + "the fix" with --fixed-dot, "the spec" with --against-dot. + --spec FILE A TLA+ file whose \\* comment lines above a rule are the rule's sentence in + the stories. Repeat it. The first file that has a sentence wins. + --view NAME customer (state labels, the default), developer (detail labels), or + layered (detail inside state). + --cover Keep only the shortest path to each settled reader state, plus the + counterexample paths. + --highlight N Draw bug N as a numbered path. Default 1. 0 draws none. + --path-only Draw only the highlighted path. + --all-labels Write every label on an edge. Default: the two most common, then " or …". + --flat Draw every state on its own, with no boxes. + --regions When every state reads "context: progress", draw two parallel regions, + context and progress, when they are smaller and proved. They draw the + steps of the shortest way to each region state, of each bug path, and + the nearest step with each label. + Otherwise draw the chart the other flags ask for, and print why. + Customer view, --highlight 0, no --flat or --path-only. + --proof FILE With --regions, write the flat chart that the proof compares with. + --regions-all FILE With --regions, also write the regions with every step. + --title TEXT The chart title. + --out FILE The Mermaid chart. Default: stdout. + --story FILE The reader stories, one per bug. + --summary FILE The counts for the chart and the bugs, as JSON. + -h, --help Print this help, and exit 0. + +The second model may have other variables and actions than the current model. Variables and +record fields that one start state has and the other does not are left out when the two models +are compared. The second model's states take the current model's start values for the variables +it does not have, so one mapping draws both. A step of an action that the second model does not +have is skipped when it changes no shared variable, and matches any step to the same shared state +when it does. + +In the customer view, states whose labels read "context: progress" with the same context sit in +one box named context, and each shows only its progress. A context with one state stays a plain +state. An arrow from a box's border stands for the same step from every state in the box: it is +drawn only when every state in the box takes that step, with the same labels, to the same state. +The layered view puts each detail state in a box named by its state label. + +With --regions, each "context: progress" label is one state in the context region and one in the +progress region. A step that happens beside only some states of the other region gets a guard: +"[when A or B]" or "[unless A or B]", whichever is shorter, and a "%% when [not] ids" line under it. +The mapping's guard words, when the step has them, replace "when A or B" in the label. +The proof combines the regions with every step as compare-charts.mjs does, and needs exactly the +flat chart's states, start states, and labeled steps. Each state's color must be the worse color +of its two region states. Then the drawn arrows, combined, must make only steps of the flat chart: +a step that they make wrongly gets back every arrow that the full regions take there. +compare-charts.mjs on the --regions-all and --proof files repeats the first proof. +mapping.regionNames = [context, progress] names the two regions. + +Without group(), the legend shows only the statuses the chart uses. A chart with a second model +has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. + +Groups: group(vars, facts) returns the name of the group that a model state is in, such as +"Toast shown", or nothing. A chart state is in a group when all its model states are in it. A +region state is in a group when every state that it is part of is in it. Each group fills its +states with one color, and the legend lists only the groups that the chart draws: first the +groups in groupColors, in that order, then the others from A to Z. groupColors, such as +{ "Toast shown": "#d6eaf8" }, gives a group a light "#rgb" or "#rrggbb" color. A group with no +color, or null, takes the next free palette color: light blue, orange, purple, yellow, teal, tan, +then pink. A state in no group, or in more than one, is gray. summary.json counts the drawn +states of each group in chartGroups, and lists the chart states with more than one in mixedGroups. +TLC decides the status, and a group cannot change it. On top of the group color, a state that +breaks a rule has a thick red border, and a fixed state or a state only in the second model a +thick green border. The legend then shows that border. A thick blue border marks the bug path on +states that work. On other states of the path, the numbered arrows show it. A group cannot have +the name of a status, such as "Breaks a rule". + +Writes only the files that --out, --story, and --summary name. + +Exit codes + 0 It wrote the chart. + 1 It cannot use an input: a mapping without state(), TLC output that it refuses, or a + --highlight with no such bug. The error says why. + 2 A bad argument.`; +const more = "Run node tlc-to-chart.mjs --help for the flags."; + +const args = { + traces: [], + fixedTraces: [], + againstTraces: [], + specs: [], + view: "customer", + highlight: 1, +}; for (let i = 2; i < process.argv.length; i++) { - const a = process.argv[i]; - const next = () => process.argv[++i]; - if (a === "--dot") args.dot = next(); - else if (a === "--map") args.map = next(); - else if (a === "--traces") args.traces.push(next()); - else if (a === "--fixed-dot") args.fixedDot = next(); - else if (a === "--fixed-traces") args.fixedTraces.push(next()); - else if (a === "--view") args.view = next(); - else if (a === "--highlight") args.highlight = Number(next()); - else if (a === "--path-only") args.pathOnly = true; - else if (a === "--all-labels") args.allLabels = true; - else if (a === "--cover") args.cover = true; - else if (a === "--title") args.title = next(); - else if (a === "--out") args.out = next(); - else if (a === "--story") args.story = next(); - else if (a === "--summary") args.summary = next(); - else { - console.error(`unknown argument ${a}`); - process.exit(2); - } -} -if (!args.dot || !args.map) { - console.error("Usage: node tlc-to-chart.mjs --dot graph.dot --map mapping.mjs [--traces tlc.out] [--fixed-dot fixed.dot] [--view customer|developer|layered]"); - process.exit(2); -} -if (!["customer", "developer", "layered"].includes(args.view)) { - console.error(`--view must be customer, developer, or layered, not ${args.view}`); - process.exit(2); + const a = process.argv[i]; + const next = () => process.argv[++i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--dot") args.dot = next(); + else if (a === "--map") args.map = next(); + else if (a === "--traces") args.traces.push(next()); + else if (a === "--fixed-dot") args.fixedDot = next(); + else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--against-dot") args.againstDot = next(); + else if (a === "--against-traces") args.againstTraces.push(next()); + else if (a === "--label") args.label = next(); + else if (a === "--spec") args.specs.push(next()); + else if (a === "--view") args.view = next(); + else if (a === "--highlight") args.highlight = Number(next()); + else if (a === "--path-only") args.pathOnly = true; + else if (a === "--all-labels") args.allLabels = true; + else if (a === "--flat") args.flat = true; + else if (a === "--regions") args.regions = true; + else if (a === "--proof") args.proof = next(); + else if (a === "--regions-all") args.regionsAll = next(); + else if (a === "--cover") args.cover = true; + else if (a === "--title") args.title = next(); + else if (a === "--out") args.out = next(); + else if (a === "--story") args.story = next(); + else if (a === "--summary") args.summary = next(); + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } } +const fail = (message) => { + console.error(`${message} ${more}`); + process.exit(2); +}; +if (!args.dot || !args.map) fail(USAGE); +if (!["customer", "developer", "layered"].includes(args.view)) + fail(`--view must be customer, developer, or layered, not ${args.view}.`); +if (args.fixedDot && args.againstDot) fail("use --fixed-dot or --against-dot, not both."); +if (args.fixedTraces.length && !args.fixedDot) fail("--fixed-traces needs --fixed-dot."); +if (args.againstTraces.length && !args.againstDot) fail("--against-traces needs --against-dot."); +if (args.label !== undefined && !args.fixedDot && !args.againstDot) + fail("--label names the second model; it needs --fixed-dot or --against-dot."); +if (args.proof && !args.regions) fail("--proof writes the regions proof; it needs --regions."); +if (args.regionsAll && !args.regions) + fail("--regions-all writes every regions arrow; it needs --regions."); +if ( + args.regions && + (args.view !== "customer" || args.flat || args.pathOnly || args.highlight !== 0) +) + fail( + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only.", + ); + +const role = args.fixedDot ? "fix" : args.againstDot ? "against" : null; +const L = args.label ?? (role === "fix" ? "the fix" : "the spec"); +const within = role === "fix" ? "with" : "in"; +const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); const mapping = await import(pathToFileURL(path.resolve(args.map)).href); -if (typeof mapping.state !== "function") throw new Error("the mapping module must export state(vars)"); -if (args.view !== "customer" && typeof mapping.detail !== "function") throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); +if (typeof mapping.state !== "function") + throw new Error("the mapping module must export state(vars)"); +if (args.view !== "customer" && typeof mapping.detail !== "function") + throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); const SEP = "\u0000"; +const rules = new Map(); +for (const f of args.specs) + for (const [name, sentence] of ruleSentences(fs.readFileSync(f, "utf8"))) + if (!rules.has(name)) rules.set(name, sentence); + const readerActions = new Set(mapping.readerActions ?? []); -const factsFrom = (enabled) => ({ enabled, settled: [...enabled].every((a) => readerActions.has(a)) }); -let factsOf = () => factsFrom(new Set()); -function keyOf(vars) { - const facts = factsOf(vars); - if (args.view === "developer") return String(mapping.detail(vars, facts)); - const outer = mapping.state(vars, facts); - if (typeof outer !== "string") throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); - return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; +const factsFrom = (enabled) => ({ + enabled, + settled: [...enabled].every((a) => readerActions.has(a)), +}); +function keyOf(vars, facts) { + if (args.view === "developer") return String(mapping.detail(vars, facts)); + const outer = mapping.state(vars, facts); + if (typeof outer !== "string") + throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); + return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; } function stepLabel(action, before, after) { - const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; - return args.view === "customer" || said === action ? said : `${said} [${action}]`; -} - -function quotient(graph) { - const keys = new Map(); - for (const n of graph.nodes.values()) keys.set(n.id, keyOf(n.vars)); - const nodes = new Set(keys.values()); - const initial = new Set([...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id))); - const edges = new Map(); - for (const e of graph.edges) { - const from = keys.get(e.from); - const to = keys.get(e.to); - if (from === to) continue; - const id = `${from}${SEP}${SEP}${to}`; - if (!edges.has(id)) edges.set(id, { from, to, counts: new Map() }); - const label = stepLabel(e.action, graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars); - if (label) edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); - } - // Most common wording first. A chart edge shows two unless --all-labels. - for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); - const settled = new Set([...graph.nodes.values()].filter((n) => factsOf(n.vars).settled).map((n) => keys.get(n.id))); - return { nodes, initial, edges, keys, settled }; -} - -function enabledIndex(g) { - const byId = new Map([...g.nodes.keys()].map((id) => [id, new Set()])); - for (const e of [...g.edges, ...g.loops]) byId.get(e.from).add(e.action); - return new Map([...g.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)])); -} -function useFacts(index) { - factsOf = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set()); + const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; + const named = args.view === "customer" || said === action ? said : `${said} [${action}]`; + const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null; + if (seconds === undefined || seconds === null || seconds === "" || !named) return named; + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) + throw new Error( + `timer() must return a number of seconds above 0, or nothing; got ${JSON.stringify(seconds)} for ${action}`, + ); + return `after ${seconds} s, ${named}`; +} +function stepGuard(action, before, after) { + if (typeof mapping.guard !== "function") return ""; + return String(mapping.guard(action, before, after) ?? "") + .replace(/[[\]]/g, "") + .trim(); +} +// A label shows guard words only when every model step behind it has some. +function guarded(guards, label) { + const words = guards?.get(label); + return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label; } +const addGuard = (guards, label, words) => { + if (!guards.has(label)) guards.set(label, new Set()); + guards.get(label).add(words); +}; -const graph = parseDot(fs.readFileSync(args.dot, "utf8")); -const graphIndex = enabledIndex(graph); -useFacts(graphIndex); -const now = quotient(graph); -const fixedGraph = args.fixedDot ? parseDot(fs.readFileSync(args.fixedDot, "utf8")) : null; -const fixedIndex = fixedGraph ? enabledIndex(fixedGraph) : null; -let fixed = null; -if (fixedGraph) { - useFacts(fixedIndex); - fixed = quotient(fixedGraph); - useFacts(graphIndex); +// ---------- projection between two models ---------- +// Paths of record fields that a has and b does not. It looks inside records only: sets and +// sequences can be empty in a start state, so they show no fields. +function lacking(a, b, prefix = []) { + return Object.keys(a).flatMap((k) => + !(k in b) + ? [[...prefix, k]] + : isRecord(a[k]) && isRecord(b[k]) + ? lacking(a[k], b[k], [...prefix, k]) + : [], + ); +} +const holder = (o, p) => p.slice(0, -1).reduce((x, k) => (isRecord(x?.[k]) ? x[k] : null), o); +function omit(vars, paths) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const o = holder(out, p); + if (o) delete o[p.at(-1)]; + } + return out; +} +function fill(vars, paths, from) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const [o, f] = [holder(out, p), holder(from, p)]; + if (o && f && p.at(-1) in f) o[p.at(-1)] = structuredClone(f[p.at(-1)]); + } + return out; } -const customerKey = (vars) => String(mapping.state(vars, factsOf(vars))); +// One model: its graph, the facts of each state, and its reader keys. +// lookup turns a state as the chart sees it back into the model's own state. +function modelOf(graph, lookup = (vars) => vars) { + const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); + for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); + const index = new Map( + [...graph.nodes.values()].map((n) => [stateKey(n.raw ?? n.vars), byId.get(n.id)]), + ); + const facts = (vars) => factsFrom(index.get(stateKey(lookup(vars))) ?? new Set()); + return { + graph, + facts, + key: (vars) => keyOf(vars, facts(vars)), + customer: (vars) => String(mapping.state(vars, facts(vars))), + }; +} + +function quotient(m) { + const { graph } = m; + const keys = new Map(); + for (const n of graph.nodes.values()) keys.set(n.id, m.key(n.vars)); + const nodes = new Set(keys.values()); + const initial = new Set( + [...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id)), + ); + const edges = new Map(); + for (const e of graph.edges) { + const from = keys.get(e.from); + const to = keys.get(e.to); + if (from === to) continue; + const id = `${from}${SEP}${SEP}${to}`; + if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() }); + const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars]; + const label = stepLabel(e.action, before, after); + if (!label) continue; + edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); + addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); + } + // Most common wording first. A chart edge shows two unless --all-labels. + for (const e of edges.values()) + e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); + const settled = new Set( + [...graph.nodes.values()].filter((n) => m.facts(n.vars).settled).map((n) => keys.get(n.id)), + ); + return { nodes, initial, edges, keys, settled }; +} + +const graph = parseDot(fs.readFileSync(args.dot, "utf8")); +const main = modelOf(graph); +const now = quotient(main); +const mainInit = [...graph.nodes.values()].find((n) => n.init).vars; + +const secondDot = args.fixedDot ?? args.againstDot; +const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null; +let lacks = []; +let extra = []; +let second = null; +let secondQ = null; +if (secondGraph) { + const secondInit = [...secondGraph.nodes.values()].find((n) => n.init).vars; + lacks = lacking(mainInit, secondInit); + extra = lacking(secondInit, mainInit); + for (const n of secondGraph.nodes.values()) { + n.raw = n.vars; + n.vars = fill(n.raw, lacks, mainInit); + } + second = modelOf(secondGraph, (vars) => omit(vars, lacks)); + secondQ = quotient(second); +} // Reader path of one trace: drop system steps that keep the reader in the same state. // Reader actions stay even then, because the reader did them. -function readerPath(trace, key0 = keyOf) { - const out = [{ key: key0(trace.steps[0].vars), label: null, modelStep: 0 }]; - for (let i = 1; i < trace.steps.length; i++) { - const key = key0(trace.steps[i].vars); - if (key === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; - const label = stepLabel(trace.steps[i].action, trace.steps[i - 1].vars, trace.steps[i].vars) || trace.steps[i].action; - out.push({ key, label, modelStep: i }); - } - return out; -} - -// Replay a trace's actions in the fixed graph. Returns where the fixed model leaves the trace. -function replayFixed(trace) { - if (!fixedGraph) return null; - const byKey = new Map([...fixedGraph.nodes.values()].map((n) => [stateKey(n.vars), n])); - const out = new Map(); - for (const e of [...fixedGraph.edges, ...fixedGraph.loops]) { - if (!out.has(e.from)) out.set(e.from, []); - out.get(e.from).push(e); - } - let cur = byKey.get(stateKey(trace.steps[0].vars)); - if (!cur) return { step: 0, reason: "the fixed model has no matching initial state" }; - for (let i = 1; i < trace.steps.length; i++) { - const want = trace.steps[i]; - const same = (out.get(cur.id) ?? []).filter((e) => e.action === want.action); - const exact = same.find((e) => stateKey(fixedGraph.nodes.get(e.to).vars) === stateKey(want.vars)); - if (exact) { - cur = fixedGraph.nodes.get(exact.to); - continue; - } - const score = (vars) => Object.keys(want.vars).filter((k) => JSON.stringify(vars[k]) === JSON.stringify(want.vars[k])).length; - const best = same.map((e) => fixedGraph.nodes.get(e.to)).sort((a, b) => score(b.vars) - score(a.vars))[0]; - const reached = best ?? cur; - useFacts(fixedIndex); - const key = keyOf(reached.vars); - useFacts(graphIndex); - return { step: i, action: want.action, disabled: !best, key }; - } - return { step: trace.steps.length, sameEnd: true }; -} - -function readTraces(files) { - return files.flatMap((f) => { - const text = fs.readFileSync(f, "utf8"); - checkTlcOutput(text, f); - return parseTraces(text); - }); +function readerPath(trace, key) { + const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; + for (let i = 1; i < trace.steps.length; i++) { + const k = key(trace.steps[i].vars); + if (k === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const label = stepLabel(trace.steps[i].action, before, after) || trace.steps[i].action; + const guard = stepGuard(trace.steps[i].action, before, after); + out.push({ key: k, label, guard, modelStep: i }); + } + return out; +} + +// Replay a counterexample on the second model, comparing only the variables both models have. +// Returns where the second model leaves the counterexample. +function replaySecond(trace) { + if (!second) return null; + const shared = (vars) => stateKey(omit(vars, lacks)); + const sharedOf = (n) => stateKey(omit(n.raw, extra)); + const out = new Map(); + for (const e of [...secondGraph.edges, ...secondGraph.loops]) { + if (!out.has(e.from)) out.set(e.from, []); + out.get(e.from).push(e); + } + const actions = new Set([...secondGraph.edges, ...secondGraph.loops].map((e) => e.action)); + const nodeOf = (id) => secondGraph.nodes.get(id); + let cur = [...secondGraph.nodes.values()].find( + (n) => n.init && sharedOf(n) === shared(trace.steps[0].vars), + ); + if (!cur) return { step: 0, reason: "starts somewhere else" }; + for (let i = 1; i < trace.steps.length; i++) { + const want = trace.steps[i]; + const target = shared(want.vars); + const from = out.get(cur.id) ?? []; + const exact = from.find((e) => e.action === want.action && sharedOf(nodeOf(e.to)) === target); + if (exact) { + cur = nodeOf(exact.to); + continue; + } + if (!actions.has(want.action)) { + if (target === sharedOf(cur)) continue; + const any = from.find((e) => sharedOf(nodeOf(e.to)) === target); + if (any) { + cur = nodeOf(any.to); + continue; + } + } + const wanted = omit(want.vars, lacks); + const score = (n) => { + const have = omit(n.raw, extra); + return Object.keys(wanted).filter((k) => stateKey(have[k]) === stateKey(wanted[k])).length; + }; + const best = from + .filter((e) => e.action === want.action) + .map((e) => nodeOf(e.to)) + .sort((a, b) => score(b) - score(a))[0]; + const before = trace.steps[i - 1].vars; + const vars = fill((best ?? cur).raw, lacks, before); + return { step: i, action: want.action, disabled: !best, key: second.key(vars), vars, before }; + } + return { step: trace.steps.length, sameEnd: true }; +} + +function readTraces(files, paths = []) { + return files.flatMap((f) => { + const text = fs.readFileSync(f, "utf8"); + checkTlcOutput(text, f); + return parseTraces(text).map((t) => ({ + ...t, + steps: t.steps.map((s) => ({ ...s, vars: fill(s.vars, paths, mainInit) })), + })); + }); } // With -continue, TLC reports every bad state, including states after the first bad one. // Cut each trace at its first bad state so one bug counts once. function firstBad(raw) { - const badStates = new Map(); - for (const t of raw) { - if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); - badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); - } - const cuts = []; - const seen = new Set(); - for (const t of raw) { - const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); - const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; - const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; - if (seen.has(id)) continue; - seen.add(id); - cuts.push(cut); - } - return cuts; + const badStates = new Map(); + for (const t of raw) { + if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); + badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); + } + const cuts = []; + const seen = new Set(); + for (const t of raw) { + const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); + const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; + const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; + if (seen.has(id)) continue; + seen.add(id); + cuts.push(cut); + } + return cuts; } const rawTraces = readTraces(args.traces); -const rawFixedTraces = readTraces(args.fixedTraces); +const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; +const rawSecondTraces = readTraces(secondTraceFiles, lacks); const traces = firstBad(rawTraces); -const fixedTraces = firstBad(rawFixedTraces); +const secondTraces = firstBad(rawSecondTraces); // Classes and their shortest traces come from the customer states in every view, // so class N is the same bug and the same trace in the customer and developer charts. -function group(cuts) { - const byId = new Map(); - for (const trace of cuts) { - const rp = readerPath(trace, customerKey); - const last = trace.steps[trace.steps.length - 1].vars; - const cause = typeof mapping.cause === "function" ? String(mapping.cause(last, factsOf(last), trace.invariant)) : null; - const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; - const prev = byId.get(id); - if (!prev || rp.length < prev.steps || (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)) { - byId.set(id, { id, cause, steps: rp.length, trace, invariant: trace.invariant, count: (prev?.count ?? 0) + 1 }); - } else prev.count++; - } - return byId; -} -const byOrder = (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); -const classesById = group(traces); +function group(cuts, m) { + const byId = new Map(); + for (const trace of cuts) { + const rp = readerPath(trace, m.customer); + const last = trace.steps[trace.steps.length - 1].vars; + const cause = + typeof mapping.cause === "function" + ? String(mapping.cause(last, m.facts(last), trace.invariant)) + : null; + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = byId.get(id); + if ( + !prev || + rp.length < prev.steps || + (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length) + ) { + byId.set(id, { + id, + cause, + steps: rp.length, + trace, + invariant: trace.invariant, + count: (prev?.count ?? 0) + 1, + }); + } else prev.count++; + } + return byId; +} +// Fewest reader steps first, then fewest model steps, then rule name, then cause (or end state). +const byOrder = (a, b) => + a.steps - b.steps || + a.trace.steps.length - b.trace.steps.length || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +const classesById = group(traces, main); const classes = [...classesById.values()].sort(byOrder); for (const c of classes) { - c.path = readerPath(c.trace); - c.end = c.path[c.path.length - 1].key; - c.fix = replayFixed(c.trace); + c.path = readerPath(c.trace, main.key); + c.end = c.path[c.path.length - 1].key; + c.second = replaySecond(c.trace); } -// A bug of the fixed model that no current bug matches is new with the fix. It numbers after the current bugs. -useFacts(fixedIndex ?? graphIndex); -const introduced = [...group(fixedTraces).values()].filter((c) => !classesById.has(c.id)).sort(byOrder); +// A bug of the second model that no current bug matches numbers after the current bugs. +const introduced = second + ? [...group(secondTraces, second).values()].filter((c) => !classesById.has(c.id)).sort(byOrder) + : []; for (const c of introduced) { - c.path = readerPath(c.trace); - c.end = c.path[c.path.length - 1].key; - c.fix = null; - c.introduced = true; + c.path = readerPath(c.trace, second.key); + c.end = c.path[c.path.length - 1].key; + c.second = null; + c.introduced = true; } -useFacts(graphIndex); classes.push(...introduced); // ---------- status ---------- // A chart state is bad when it holds a state that breaks a rule, or when every path from the start to -// it passes such a state: what the reader sees after a bug is part of that bug. It is still wrong when -// it is bad in the fixed model, and fixed when it is bad only in the current model. -// Without --fixed-traces every bad state is wrong. -const isFixed = (c) => c.fix?.key !== undefined && c.fix.key !== c.end; -function badKeys(g, keys, raw) { - const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); - const out = new Set(raw.map((t) => keyOf(t.steps[t.steps.length - 1].vars))); - if (!g) return out; - const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); - const next = new Map(); - for (const e of g.edges) { - if (!next.has(e.from)) next.set(e.from, []); - next.get(e.from).push(e.to); - } - const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); - const queue = [...clean]; - while (queue.length) { - for (const to of next.get(queue.shift()) ?? []) { - if (clean.has(to) || !ok(to)) continue; - clean.add(to); - queue.push(to); - } - } - const reached = new Set([...clean].map((id) => keys.get(id))); - for (const k of keys.values()) if (!reached.has(k)) out.add(k); - return out; -} -const badNow = badKeys(graph, now.keys, rawTraces); -useFacts(fixedIndex ?? graphIndex); -const badFixed = args.fixedTraces.length > 0 ? badKeys(fixedGraph, fixed?.keys, rawFixedTraces) : badNow; -useFacts(graphIndex); -const allNodes = new Set([...now.nodes, ...(fixed?.nodes ?? [])]); -const status = (k) => (badFixed.has(k) ? "wrong" : badNow.has(k) || (fixed && !now.nodes.has(k)) ? "fixed" : "works"); +// it passes such a state: what the reader sees after a bug is part of that bug. +// With a fix, a state is still wrong when it is bad in the fixed model, and fixed when it is bad only +// in the current model; without --fixed-traces every bad state is wrong. +// Against a spec, a state is wrong when it is bad in either model, and green when only the spec has it. +const diverges = (c) => c.second?.key !== undefined && c.second.key !== c.end; +function badKeys(m, keys, raw) { + const g = m.graph; + const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); + const out = new Set(raw.map((t) => m.key(t.steps[t.steps.length - 1].vars))); + const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); + const next = new Map(); + for (const e of g.edges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); + const queue = [...clean]; + while (queue.length) { + for (const to of next.get(queue.shift()) ?? []) { + if (clean.has(to) || !ok(to)) continue; + clean.add(to); + queue.push(to); + } + } + const reached = new Set([...clean].map((id) => keys.get(id))); + for (const k of keys.values()) if (!reached.has(k)) out.add(k); + return out; +} +const badNow = badKeys(main, now.keys, rawTraces); +const badSecond = + second && secondTraceFiles.length > 0 + ? badKeys(second, secondQ.keys, rawSecondTraces) + : role === "fix" + ? badNow + : new Set(); +const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); +for (const c of classes) if (diverges(c)) allNodes.add(c.second.key); +const onlySecond = (k) => second && !now.nodes.has(k); +const status = (k) => { + if (role === "against") + return badNow.has(k) || badSecond.has(k) ? "wrong" : onlySecond(k) ? "fixed" : "works"; + if (role === "fix") + return badSecond.has(k) ? "wrong" : badNow.has(k) || onlySecond(k) ? "fixed" : "works"; + return badNow.has(k) ? "wrong" : "works"; +}; const edges = new Map(now.edges); -if (fixed) { - for (const [id, e] of fixed.edges) { - if (!edges.has(id)) edges.set(id, { ...e, labels: new Set([...e.labels].map((l) => `${l} (with the fix)`)), fixedOnly: true }); - } -} -// The fix changes one model step of the counterexample. Its edge leaves the chart state before that -// step and ends where the fixed model goes instead, or stays put when the fix blocks the step. -const fixEdgeId = (c) => `${c.fix.from}${SEP}${SEP}${c.fix.key}${SEP}fix`; +if (secondQ) { + const only = (l) => `${l} (${within} ${L})`; + for (const [id, e] of secondQ.edges) { + if (!edges.has(id)) + edges.set(id, { + ...e, + labels: new Set([...e.labels].map(only)), + guards: new Map([...e.guards].map(([l, words]) => [only(l), words])), + secondOnly: true, + }); + } +} +// The second model changes one model step of the counterexample. Its edge leaves the chart state +// before that step and ends where the second model goes instead, or stays put when the second model +// cannot take the step. +const secondEdgeId = (c) => `${c.second.from}${SEP}${SEP}${c.second.key}${SEP}second`; for (const c of classes) { - if (!isFixed(c)) continue; - const [before, after] = [c.trace.steps[c.fix.step - 1].vars, c.trace.steps[c.fix.step].vars]; - c.fix.from = keyOf(before); - const said = stepLabel(c.fix.action, before, after) || c.fix.action; - const id = fixEdgeId(c); - if (!edges.has(id)) edges.set(id, { from: c.fix.from, to: c.fix.key, labels: new Set(), fixedOnly: true, fixOf: new Set() }); - edges.get(id).labels.add(c.fix.disabled ? `${said}, blocked by the fix` : `${said}, with the fix`); - edges.get(id).fixOf.add(c); + if (!diverges(c)) continue; + const { action, before } = c.second; + const after = c.trace.steps[c.second.step].vars; + c.second.from = main.key(before); + c.second.said = stepLabel(action, before, after) || action; + c.second.said2 = stepLabel(action, before, c.second.vars) || c.second.said; + const id = secondEdgeId(c); + if (!edges.has(id)) + edges.set(id, { + from: c.second.from, + to: c.second.key, + labels: new Set(), + secondOnly: true, + secondOf: new Set(), + }); + const blocked = role === "fix" ? `blocked by ${L}` : `not in ${L}`; + edges + .get(id) + .labels.add( + c.second.disabled ? `${c.second.said}, ${blocked}` : `${c.second.said2}, ${within} ${L}`, + ); + edges.get(id).secondOf.add(c); } // ---------- highlighted path ---------- const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; -if (args.highlight > 0 && classes.length > 0 && !chosen) throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); +if (args.highlight > 0 && classes.length > 0 && !chosen) + throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); const pathNumbers = new Map(); if (chosen) { - chosen.path.slice(1).forEach((p, i) => { - const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; - if (!pathNumbers.has(id)) pathNumbers.set(id, { n: [], label: p.label }); - pathNumbers.get(id).n.push(i + 1); - if (!edges.has(id)) edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); - }); - if (isFixed(chosen)) onPath.add(chosen.fix.key); + chosen.path.slice(1).forEach((p, i) => { + const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; + if (!pathNumbers.has(id)) + pathNumbers.set(id, { + n: [], + label: p.label, + shown: p.guard ? `${p.label} [${p.guard}]` : p.label, + }); + pathNumbers.get(id).n.push(i + 1); + if (!edges.has(id)) + edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); + }); + if (diverges(chosen)) onPath.add(chosen.second.key); } // ---------- cover: shortest path to each settled reader state ---------- let coverEdges = null; if (args.cover) { - const keepEdges = new Set(); - const bfs = (q, targets) => { - const parent = new Map([...q.initial].map((k) => [k, null])); - const queue = [...q.initial]; - const outEdges = new Map(); - for (const [id, e] of q.edges) { - if (!outEdges.has(e.from)) outEdges.set(e.from, []); - outEdges.get(e.from).push([id, e]); - } - while (queue.length) { - const k = queue.shift(); - for (const [id, e] of outEdges.get(k) ?? []) { - if (parent.has(e.to)) continue; - parent.set(e.to, id); - queue.push(e.to); - } - } - for (const t of targets) { - for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); - } - }; - bfs(now, now.settled); - if (fixed) bfs(fixed, [...fixed.settled].filter((k) => !now.nodes.has(k))); - for (const c of classes) { - c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); - if (isFixed(c)) keepEdges.add(fixEdgeId(c)); - } - for (const id of keepEdges) { - if (!edges.has(id)) { - const [from, to] = id.split(`${SEP}${SEP}`); - const c = classes.find((x) => x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to)); - const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); - edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); - } - } - coverEdges = keepEdges; + const keepEdges = new Set(); + const bfs = (q, targets) => { + const parent = new Map([...q.initial].map((k) => [k, null])); + const queue = [...q.initial]; + const outEdges = new Map(); + for (const [id, e] of q.edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from).push([id, e]); + } + while (queue.length) { + const k = queue.shift(); + for (const [id, e] of outEdges.get(k) ?? []) { + if (parent.has(e.to)) continue; + parent.set(e.to, id); + queue.push(e.to); + } + } + for (const t of targets) { + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); + } + }; + bfs(now, now.settled); + if (secondQ) + bfs( + secondQ, + [...secondQ.settled].filter((k) => !now.nodes.has(k)), + ); + for (const c of classes) { + c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); + if (diverges(c)) keepEdges.add(secondEdgeId(c)); + } + for (const id of keepEdges) { + if (!edges.has(id)) { + const [from, to] = id.split(`${SEP}${SEP}`); + const c = classes.find((x) => + x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to), + ); + const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); + edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); + } + } + coverEdges = keepEdges; } // ---------- Mermaid ---------- +const LEGEND = { + works: ["legendWorks", role === "fix" ? "Works today" : "Works"], + wrong: ["legendWrong", role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"], + fixed: ["legendFixed", role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`], +}; + +// ---------- groups ---------- +// The mapping's group() names the group of each model state, and a chart state takes the group +// that all its model states share. The fill shows the group. The status from TLC stays a border on +// top, so no group hides a broken rule or the second model's states. +const grouping = typeof mapping.group === "function"; +const groupsOf = new Map(); +const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : ""); +let groupNames = []; +const fills = new Map(); +if (grouping) { + const add = (k, vars, m) => { + const g = mapping.group(vars, m.facts(vars)); + if (g !== undefined && g !== null && typeof g !== "string") + throw new Error(`group() must return a group name or nothing; got ${JSON.stringify(g)}`); + if (!groupsOf.has(k)) groupsOf.set(k, new Set()); + groupsOf.get(k).add((g ?? "").replace(/\s+/g, " ").trim()); + }; + for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); + if (second) + for (const n of secondGraph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); + for (const c of classes) if (diverges(c)) add(c.second.key, c.second.vars, second); + + const given = mapping.groupColors ?? {}; + if (!isRecord(given)) + throw new Error("groupColors must be an object from a group name to a color such as #d6eaf8"); + const statusNames = new Set( + [ + "Works", + "Works today", + "Breaks a rule", + "Still wrong", + "Fixed", + ...Object.values(LEGEND).map(([, t]) => t), + ].map((t) => t.toLowerCase()), + ); + const found = new Set([...groupsOf.values()].flatMap((gs) => [...gs]).filter(Boolean)); + for (const g of found) + if (statusNames.has(g.toLowerCase())) + throw new Error( + `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`, + ); + groupNames = [ + ...Object.keys(given).filter((g) => found.has(g)), + ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(), + ]; + for (const [g, color] of Object.entries(given)) { + if (color === undefined || color === null || color === "") continue; + const why = groupColorProblem(color); + if (why) throw new Error(`groupColors["${g}"] ${JSON.stringify(color)} ${why}`); + if (found.has(g)) fills.set(g, color.toLowerCase()); + } + const free = GROUP_PALETTE.filter((c) => ![...fills.values()].includes(c)); + for (const g of groupNames) { + if (fills.has(g)) continue; + if (!free.length) + throw new Error( + `the mapping has ${groupNames.length} groups, more than the ${GROUP_PALETTE.length} colors of the palette; give the others a color in groupColors`, + ); + fills.set(g, free.shift()); + } +} +const mixedGroups = [...groupsOf].filter(([, gs]) => gs.size > 1).map(([k]) => k); + +const BORDER = { works: "", wrong: "Wrong", fixed: "Fixed" }; +const classFor = (g, s, path) => + grouping + ? `${g ? `group${groupNames.indexOf(g)}` : "plain"}${BORDER[s] || (path ? "Path" : "")}` + : `${s}${path ? "Path" : ""}`; +const drawnState = (g, s, path = false) => ({ group: g, status: s, cls: classFor(g, s, path) }); +const groupCounts = (drawn) => + groupNames + .map((g) => ({ + name: g, + color: fills.get(g), + states: drawn.filter((d) => d.group === g).length, + })) + .filter((g) => g.states > 0); + +// drawn holds { group, status, cls } for each state the chart draws. +function head(drawn) { + const out = []; + if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); + out.push("stateDiagram-v2"); + if (role) out.push(` %% second model (${role}): ${L}`); + const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s)); + const legend = grouping + ? [ + ...groupCounts(drawn).map(({ name }) => { + const i = groupNames.indexOf(name); + return [`legendGroup${i}`, name, `group${i}`]; + }), + ...used.filter((s) => BORDER[s]).map((s) => [...LEGEND[s], `plain${BORDER[s]}`]), + ] + : used.map((s) => [...LEGEND[s], s]); + if (grouping) { + const classes = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); + const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])]; + for (const [base, fill] of bases) + for (const border of ["", "Wrong", "Fixed", "Path"]) + if (classes.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); + } else for (const c of STATUS_CLASSES) out.push(` ${c}`); + if (legend.length) { + out.push(` state "Legend" as ${LEGEND_ID} {`); + for (const [id, text] of legend) out.push(` state "${quote(text)}" as ${id}`); + out.push(" }"); + for (const [id, , c] of legend) out.push(` class ${id} ${c}`); + } + return out; +} + +// ---------- parallel regions ---------- +// Splits each "context: progress" label into a context region and a progress region. A step of +// one region that happens beside only some states of the other gets a guard. The chart is used +// only when it has fewer states than the flat chart, and when, read back, its combined machine +// has exactly the flat chart's states, start states, and labeled steps. +function regionsChart() { + const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim(); + const split = (k) => { + const at = k.indexOf(": "); + return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null; + }; + const nodes = [...allNodes].sort(); + if (!nodes.every(split)) return null; + const starts = [...new Set([...now.initial, ...(secondQ?.initial ?? [])])]; + const steps = []; + for (const e of edges.values()) { + if (e.labels.size === 0) return { why: "a step has no label" }; + for (const l of e.labels) + steps.push({ + from: split(e.from), + to: split(e.to), + label: norm(l), + words: e.guards?.get(l) ?? new Set([""]), + }); + } + const bar = (p) => p.join(" | "); + const flat = { + Main: { + states: new Set(nodes.map((k) => bar(split(k)))), + initial: new Set(starts.map((k) => bar(split(k)))), + transitions: steps.map((s) => ({ from: bar(s.from), to: bar(s.to), label: s.label })), + }, + }; + const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`])); + const proof = [ + "stateDiagram-v2", + ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), + ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), + ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), + ]; + if (args.proof) fs.writeFileSync(args.proof, proof.join("\n") + "\n"); + + // Each region state, and the states of the other region that it is part of a state with. + const beside = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + if (!beside[i].has(p[i])) beside[i].set(p[i], new Set()); + beside[i].get(p[i]).add(p[1 - i]); + } + } + const size = beside[0].size + beside[1].size; + if (size >= nodes.length) + return { why: `${size} region states are not fewer than the ${nodes.length} flat states` }; + + // A region state's status is the lowest status of the states it is part of, and each state must + // then show the higher status of its two region states. + const rank = ["works", "fixed", "wrong"]; + const regionStatus = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionStatus[i].get(p[i]); + if (!prev || rank.indexOf(status(k)) < rank.indexOf(prev)) + regionStatus[i].set(p[i], status(k)); + } + } + for (const k of nodes) { + const p = split(k); + const shown = rank[Math.max(...[0, 1].map((i) => rank.indexOf(regionStatus[i].get(p[i]))))]; + if (shown !== status(k)) + return { + why: `"${k}" is "${LEGEND[status(k)][1]}", and its two region states would show "${LEGEND[shown][1]}"`, + }; + } + + // A region takes every step it has, so a step that leaves a region state in some states and keeps + // it in others needs a loop there: the loop says the region may also stay. + const moves = [new Map(), new Map()]; + for (const s of steps) + for (const i of [0, 1]) { + const id = [s.from[i], s.label, s.to[i]].join(SEP); + if (!moves[i].has(id)) + moves[i].set(id, { + from: s.from[i], + label: s.label, + to: s.to[i], + beside: new Set(), + guards: new Map(), + }); + moves[i].get(id).beside.add(s.from[1 - i]); + for (const w of s.words) addGuard(moves[i].get(id).guards, s.label, norm(w)); + } + for (const m of moves) + for (const [id, x] of m) + if ( + x.from === x.to && + ![...m.values()].some((y) => y.from === x.from && y.label === x.label && y.to !== x.from) + ) + m.delete(id); + const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`]))); + // A region state is in a group when every state it is part of is in that group. + const regionGroup = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionGroup[i].get(p[i]); + regionGroup[i].set(p[i], prev === undefined || prev === groupOf(k) ? groupOf(k) : ""); + } + } + const drawn = rid.map((m, i) => + [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))), + ); + // keep holds the ids of the arrows to draw in each region, or is null for every arrow. + const draw = (keep) => { + const out = head(drawn.flat()); + out.push(` state "At the same time" as regions {`); + let count = 0; + for (const i of [0, 1]) { + if (i) out.push(" --"); + const name = mapping.regionNames?.[i]; + const pad = name ? " " : " "; + if (name) out.push(` state "${quote(wrap(name))}" as region${i} {`); + for (const s of new Set(starts.map((k) => split(k)[i]))) + out.push(`${pad}[*] --> ${rid[i].get(s)}`); + for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); + const sorted = [...moves[i]] + .filter(([id]) => !keep || keep[i].has(id)) + .map(([, m]) => m) + .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label)); + for (const m of sorted) { + // The guard names the shorter of the states the step happens beside, or those it does not. + // The mapping's guard words, when it has them, replace those names in the label. + const all = beside[i].get(m.from); + const not = [...all].filter((s) => !m.beside.has(s)).sort(); + const yes = [...m.beside].sort(); + const unless = not.length > 0 && not.length < yes.length; + const guard = not.length === 0 ? null : unless ? not : yes; + const worded = guarded(m.guards, m.label); + const text = + worded !== m.label || !guard + ? worded + : `${m.label} [${unless ? "unless" : "when"} ${guard.join(" or ")}]`; + out.push(`${pad}${rid[i].get(m.from)} --> ${rid[i].get(m.to)} : ${wrap(text, 30)}`); + if (guard) + out.push( + `${pad}%% when ${unless ? "not " : ""}${guard.map((s) => rid[1 - i].get(s)).join(", ")}`, + ); + count++; + } + if (name) out.push(" }"); + } + out.push(" }"); + for (const i of [0, 1]) + [...rid[i].values()].forEach((id, j) => out.push(` class ${id} ${drawn[i][j].cls}`)); + return { mermaid: out.join("\n") + "\n", count }; + }; + + const all = draw(null); + const differences = compareCharts(parseMermaid(all.mermaid).regions, flat); + if (differences.length) { + const n = differences.reduce((sum, [, xs]) => sum + xs.length, 0); + return { + why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`, + }; + } + if (args.regionsAll) fs.writeFileSync(args.regionsAll, all.mermaid); + + // The overview keeps each bug path, then a way from the start to each region state and one step + // with each label, as the arrows of the regions that each step moves. It adds the cheapest of + // those next: the fewest new arrows, then the fewest steps. A region with fewer arrows can stay + // where the full chart moves it, so each step that the kept arrows make and TLC's graph does + // not have gets back every arrow that the full chart takes there. + const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)])); + const key = (from, label, to) => `${from} --> ${to} : ${label}`; + const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to)))); + const next = new Map(); + for (const s of steps) { + if (!next.has(bar(s.from))) next.set(bar(s.from), []); + next.get(bar(s.from)).push(s); + } + const keep = [new Set(), new Set()]; + const movesOf = (s) => + [0, 1] + .filter((i) => s.from[i] !== s.to[i]) + .map((i) => [i, [s.from[i], s.label, s.to[i]].join(SEP)]); + const price = (s) => movesOf(s).filter(([i, id]) => !keep[i].has(id)).length * nodes.length + 1; + const wanted = []; + const want = (s) => { + wanted.push(s); + for (const [i, id] of movesOf(s)) keep[i].add(id); + }; + const stepsOf = (e, said) => { + const labels = [...e.labels].map(norm); + return (labels.includes(said) ? [said] : labels).map((label) => ({ + from: split(e.from), + to: split(e.to), + label, + })); + }; + for (const c of classes) { + c.path.slice(1).forEach((p, j) => { + const e = edges.get(`${c.path[j].key}${SEP}${SEP}${p.key}`); + if (e) stepsOf(e, norm(p.label)).forEach(want); + }); + if (diverges(c)) stepsOf(edges.get(secondEdgeId(c)), "").forEach(want); + } + const cheapest = () => { + const cost = new Map(starts.map((k) => [bar(split(k)), 0])); + const parent = new Map(); + const open = new Set(cost.keys()); + while (open.size) { + const k = [...open].reduce((a, b) => (cost.get(b) < cost.get(a) ? b : a)); + open.delete(k); + for (const s of next.get(k) ?? []) { + const c = cost.get(k) + price(s); + if (c < (cost.get(bar(s.to)) ?? Infinity)) { + cost.set(bar(s.to), c); + parent.set(bar(s.to), s); + open.add(bar(s.to)); + } + } + } + return { cost, parent }; + }; + const targets = [ + ...[0, 1].flatMap((i) => + [...beside[i].keys()].sort().map((r) => ({ at: (k) => parts.get(k)[i] === r })), + ), + ...[...new Set(steps.map((s) => s.label))] + .filter((l) => !wanted.some((s) => s.label === l)) + .sort() + .map((l) => ({ ends: (s) => s.label === l })), + ]; + while (targets.length) { + const { cost, parent } = cheapest(); + let best = null; + targets.forEach((t, n) => { + const ways = t.at + ? [...cost.keys()].filter(t.at).map((k) => ({ k, c: cost.get(k), last: null })) + : steps + .filter(t.ends) + .filter((s) => cost.has(bar(s.from))) + .map((s) => ({ k: bar(s.from), c: cost.get(bar(s.from)) + price(s), last: s })); + for (const w of ways) if (!best || w.c < best.c) best = { ...w, n }; + }); + if (!best) break; + targets.splice(best.n, 1); + const path = best.last ? [best.last] : []; + for (let s = parent.get(best.k); s; s = parent.get(bar(s.from))) path.push(s); + path.forEach(want); + } + const takes = (x, label) => + [0, 1].flatMap((i) => + [...moves[i]] + .filter(([, m]) => m.from === x[i] && m.label === label && m.beside.has(x[1 - i])) + .map(([id]) => [i, id]), + ); + let scoped; + let made; + for (;;) { + scoped = draw(keep); + made = productOf(parseMermaid(scoped.mermaid).regions); + const drawn = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); + const redo = [ + ...made.transitions + .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to))) + .map((t) => [parts.get(t.from), t.label]), + ...wanted + .filter((s) => made.states.has(bar(s.from))) + .filter((s) => !drawn.has(key(bar(s.from), s.label, bar(s.to)))) + .map((s) => [s.from, s.label]), + ]; + const before = keep[0].size + keep[1].size; + for (const [x, label] of redo) for (const [i, id] of takes(x, label)) keep[i].add(id); + if (keep[0].size + keep[1].size === before) break; + } + const sound = + [...made.states].every((s) => parts.has(s)) && + made.transitions.every((t) => real.has(key(t.from, t.label, t.to))); + if (!sound) scoped = all; + + return { + mermaid: scoped.mermaid, + states: [beside[0].size, beside[1].size], + transitions: scoped.count, + all: all.count, + flat: { states: nodes.length, transitions: steps.length }, + status: Object.fromEntries( + rank.map((s) => [ + s, + regionStatus.reduce((n, m) => n + [...m.values()].filter((x) => x === s).length, 0), + ]), + ), + groups: groupCounts(drawn.flat()), + }; +} +const regions = args.regions ? regionsChart() : null; +if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`); + let keep = allNodes; if (args.pathOnly && chosen) keep = onPath; else if (coverEdges) { - keep = new Set([...now.initial, ...(fixed?.initial ?? [])]); - for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); + keep = new Set([...now.initial, ...(secondQ?.initial ?? [])]); + for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); } const ids = new Map(); let counter = 0; const idOf = (k) => { - if (!ids.has(k)) ids.set(k, `s${counter++}`); - return ids.get(k); + if (!ids.has(k)) ids.set(k, `s${counter++}`); + return ids.get(k); }; -const label = (k) => (args.view === "layered" ? k.split(SEP)[1] : k); -const out = []; -if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); -out.push("stateDiagram-v2"); -for (const c of STATUS_CLASSES) out.push(` ${c}`); -out.push(` state "Legend" as ${LEGEND_ID} {`); -out.push(' state "Works today" as legendWorks', ' state "Still wrong" as legendWrong', ' state "Fixed or new in an open PR" as legendFixed'); -out.push(" }", " class legendWorks works", " class legendWrong wrong", " class legendFixed fixed"); const sorted = [...keep].sort(); -if (args.view === "layered") { - const groups = new Map(); - for (const k of sorted) { - const outer = k.split(SEP)[0]; - if (!groups.has(outer)) groups.set(outer, []); - groups.get(outer).push(k); - } - let g = 0; - for (const [outer, members] of groups) { - out.push(` state "${quote(wrap(outer))}" as g${g++} {`); - for (const k of members) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); - out.push(" }"); - } -} else { - for (const k of sorted) out.push(` state "${quote(wrap(label(k)))}" as ${idOf(k)}`); -} -const initial = new Set([...now.initial, ...(fixed?.initial ?? [])]); -for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); +const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k))); + +// Boxes: the states of one outer layer (layered view), or, in the customer view, the states whose +// labels read "context: progress" with the same context. A context with one state stays a plain state. +const boxes = new Map(); +const boxOf = new Map(); +const inBox = (k, box) => { + if (!boxes.has(box)) boxes.set(box, []); + boxes.get(box).push(k); +}; +if (args.view === "layered") for (const k of sorted) inBox(k, k.split(SEP)[0]); +else if (args.view === "customer" && !args.flat) { + for (const k of sorted) { + const at = k.indexOf(": "); + if (at > 0 && at + 2 < k.length) inBox(k, k.slice(0, at)); + } + for (const [box, members] of boxes) if (members.length < 2) boxes.delete(box); +} +const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`])); +for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); +const caption = (k) => + args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; + let edgeCount = 0; +const drawnLabels = new Set(); +const lines = []; for (const [id, e] of edges) { - if (!keep.has(e.from) || !keep.has(e.to)) continue; - if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.fixOf?.has(chosen)) continue; - if (coverEdges && !coverEdges.has(id)) continue; - const all = [...e.labels]; - let text = args.allLabels ? all.join(" or ") : all.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); - const p = pathNumbers.get(id); - if (p) text = `${p.n.join(", ")}. ${p.label}`; - // ";" ends a Mermaid statement, even inside a transition label. - text = text.replaceAll(";", ","); - out.push(` ${idOf(e.from)} --> ${idOf(e.to)}${text ? ` : ${wrap(text, 30)}` : ""}`); - edgeCount++; -} -for (const k of sorted) out.push(` class ${idOf(k)} ${status(k)}${onPath.has(k) ? "Path" : ""}`); -const mermaid = out.join("\n") + "\n"; + if (!keep.has(e.from) || !keep.has(e.to)) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.secondOf?.has(chosen)) continue; + if (coverEdges && !coverEdges.has(id)) continue; + const all = [...e.labels]; + const shown = all.map((l) => guarded(e.guards, l)); + let text = args.allLabels + ? shown.join(" or ") + : shown.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + const p = pathNumbers.get(id); + if (p) text = `${p.n.join(", ")}. ${p.shown}`; + for (const l of p ? [p.label] : all) drawnLabels.add(l); + // ";" ends a Mermaid statement, even inside a transition label. + text = text.replaceAll(";", ","); + lines.push({ from: e.from, to: e.to, text, plain: !p && !e.secondOf, labels: [...all].sort() }); + edgeCount++; +} +// One arrow from a box's border stands for the same step from every state in the box. It is drawn +// only when every state in the box has that step, with the same labels, to the same state outside +// the box, so the chart still says exactly what each state does. +const fromBox = []; +for (const [box, members] of boxes) { + const mine = new Set(members); + const byStep = new Map(); + for (const l of lines) { + if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue; + const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`; + if (!byStep.has(step)) byStep.set(step, []); + byStep.get(step).push(l); + } + for (const same of byStep.values()) { + if (new Set(same.map((l) => l.from)).size !== members.length) continue; + for (const l of same) lines.splice(lines.indexOf(l), 1); + fromBox.push({ ...same[0], box }); + } +} + +const out = head(drawn); +for (const [box, members] of boxes) { + out.push(` state "${quote(wrap(box))}" as ${boxIds.get(box)} {`); + for (const k of members) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); + out.push(" }"); +} +for (const k of sorted) + if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); +const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); +for (const k of sorted) + if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) + out.push(` [*] --> ${idOf(k)}`); +const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${wrap(l.text, 30)}` : ""}`; +for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); +for (const l of lines) out.push(arrow(idOf(l.from), l)); +sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); +const mermaid = regions?.mermaid ?? out.join("\n") + "\n"; if (args.out) fs.writeFileSync(args.out, mermaid); else process.stdout.write(mermaid); // ---------- stories and summary ---------- const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); +const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; +// The reader step of a model step, or the last reader step before it when the reader saw no change. +function readerStep(c, step) { + const at = c.path.findIndex((p) => p.modelStep === step); + if (at >= 0) return { at }; + return { after: c.path.findLastIndex((p) => p.modelStep < step) }; +} +function secondLine(c) { + const W = cap(within); + if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`; + const s = c.second; + if (!s) return null; + if (s.sameEnd) return `${W} ${L}, the same steps end in the same place.`; + if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; + const { at, after } = readerStep(c, s.step); + if (s.disabled) + return at !== undefined + ? `${W} ${L}, step ${at} cannot happen, so you stay at: ${show(s.key)}.` + : `${W} ${L}, this path ends after step ${after}, and you stay at: ${show(s.key)}.`; + return at !== undefined + ? `${W} ${L}, step ${at} is "${s.said2}", and you see: ${show(s.key)}.` + : `${W} ${L}, after step ${after}, "${s.said2}" happens, and you see: ${show(s.key)}.`; +} if (args.story) { - const lines = []; - classes.forEach((c, i) => { - lines.push(`## ${i + 1}. ${c.introduced ? "New with the fix: " : ""}${show(c.end)}`, ""); - lines.push(`Breaks \`${c.invariant}\`${c.cause ? ` (${c.cause})` : ""}. Shortest of ${c.count} counterexample(s) that end here: ${c.trace.steps.length} model steps, ${c.path.length - 1} reader steps.`, ""); - c.path.forEach((p, j) => lines.push(j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${p.label}. Now: ${show(p.key)}`)); - lines.push(""); - if (c.introduced) lines.push("Only the fixed model has this bug. The path above is in the fixed model."); - else if (!c.fix) lines.push("No fixed model given."); - else if (c.fix.sameEnd) lines.push("The fixed model follows the whole trace: the fix does not change this path."); - else if (c.fix.reason) lines.push(`The fixed model cannot replay this trace: ${c.fix.reason}.`); - else lines.push(`With the fix, model step ${c.fix.step} (\`${c.fix.action}\`) ${c.fix.disabled ? "cannot happen" : "goes elsewhere"}, and you see: ${show(c.fix.key)}`); - lines.push(""); - }); - fs.writeFileSync(args.story, lines.join("\n")); + const lines = []; + const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `; + classes.forEach((c, i) => { + lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, ""); + lines.push(`Rule broken: ${ruleOf(c)}`); + if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`); + lines.push( + c.count === 1 + ? "This is the only way to get here in the model." + : `There are ${c.count} ways to get here in the model. This is the shortest.`, + "", + ); + c.path.forEach((p, j) => + lines.push( + j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${cap(p.label)}. Now: ${show(p.key)}`, + ), + ); + lines.push(""); + const said = secondLine(c); + if (said) lines.push(said, ""); + }); + fs.writeFileSync(args.story, lines.join("\n")); } +// Labels a product reader would not say: camelCase, snake_case, calls, and joined PascalCase words. +const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/; +const codeLike = [ + ...new Set([ + ...(args.view === "customer" ? [...sorted, ...drawnLabels] : []), + ...classes.map((c) => c.cause).filter(Boolean), + ]), +].filter((t) => CODE_LIKE.test(t)); +const secondOf = (c) => + c.introduced + ? role === "fix" + ? `new with ${L}` + : `only in ${L}` + : c.second + ? c.second.sameEnd + ? "unchanged" + : (c.second.reason ?? show(c.second.key)) + : null; const summary = { - view: args.view, - modelStates: graph.nodes.size, - modelTransitions: graph.edges.length, - chartStates: keep.size, - chartTransitions: edgeCount, - chartStatus: Object.fromEntries(["works", "wrong", "fixed"].map((s) => [s, [...keep].filter((k) => status(k) === s).length])), - classes: classes.map((c, i) => ({ - n: i + 1, - end: show(c.end), - invariant: c.invariant, - cause: c.cause, - readerSteps: c.path.length - 1, - modelSteps: c.trace.steps.length, - counterexamples: c.count, - fix: c.introduced ? "new with the fix" : c.fix ? (c.fix.sameEnd ? "unchanged" : c.fix.reason ?? show(c.fix.key)) : null, - })), + view: args.view, + ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), + modelStates: graph.nodes.size, + modelTransitions: graph.edges.length, + chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : keep.size, + chartTransitions: regions?.mermaid ? regions.transitions : edgeCount, + chartStatus: regions?.mermaid + ? regions.status + : Object.fromEntries( + ["works", "wrong", "fixed"].map((s) => [ + s, + [...keep].filter((k) => status(k) === s).length, + ]), + ), + ...(grouping + ? { + chartGroups: regions?.mermaid ? regions.groups : groupCounts(drawn), + mixedGroups: mixedGroups.map(show).sort(), + } + : {}), + ...(regions + ? { + regions: regions.mermaid + ? { + drawn: true, + states: regions.states, + transitions: regions.transitions, + all: regions.all, + flat: regions.flat, + } + : { drawn: false, why: regions.why }, + } + : {}), + classes: classes.map((c, i) => ({ + n: i + 1, + end: show(c.end), + invariant: c.invariant, + rule: rules.get(c.invariant) ?? null, + cause: c.cause, + readerSteps: c.path.length - 1, + modelSteps: c.trace.steps.length, + counterexamples: c.count, + [role ?? "fix"]: secondOf(c), + })), + codeLike, }; if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); +const extraBugs = introduced.length + ? `, ${introduced.length} ${role === "fix" ? `new with ${L}` : `only in ${L}`}` + : ""; +const drawnAs = regions?.mermaid + ? ` of ${regions.all} in 2 regions (proved against ${regions.flat.states} states, ${regions.flat.transitions} transitions)` + : ""; console.error( - `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions from ${summary.modelStates} model states; ` + - `${classes.length - introduced.length} bug(s)${introduced.length ? `, ${introduced.length} new with the fix` : ""}`, + `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` + + `${classes.length - introduced.length} bug(s)${extraBugs}`, ); From 6e05b22a58ffe4feb120600aec21f7082b269a16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 21:34:19 +0000 Subject: [PATCH 6/9] Exit 2 when --png cannot render a chart A failed mermaid-cli render printed "could not render" but still exited 0, or 0 with --expect-broken, so callers reported PNGs that did not exist. finish() now exits 2 whenever --png renders fewer PNGs than charts, and keeps the files it wrote. The --help exit codes say so, and a new test covers it with an npx that cannot launch a browser. Co-authored-by: lauren --- .../skills/statechart/scripts/statechart.mjs | 6 ++-- .../scripts/test/statechart.test.mjs | 36 ++++++++++++++++++- .../skills/statechart/scripts/statechart.mjs | 6 ++-- .../scripts/test/statechart.test.mjs | 36 ++++++++++++++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs index b69911ae0..86ff7154a 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs @@ -131,7 +131,9 @@ Exit codes 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not CHART.mmd. The output says which. 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it - cannot read, no TLC, or a new mapping to fill in.`; + cannot read, no TLC, a new mapping to fill in, or a chart that --png cannot render. When a + chart does not render, the command keeps the files it wrote and exits 2, whatever the rules + give.`; const HELP = { "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} @@ -470,7 +472,7 @@ function finish(out, charts, files, ok) { const pngs = args.png ? render(charts) : []; const written = [...charts, ...pngs, ...files.slice(1)].map((f) => path.basename(f)).join(", "); console.log(`\nWrote ${written} in ${rel(out)}.`); - process.exit(ok ? 0 : 1); + process.exit(args.png && pngs.length < charts.length ? 2 : ok ? 0 : 1); } const outDir = (name) => { diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 545d76a23..362968a47 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { @@ -982,6 +982,40 @@ Wrote chart.mmd, stories.md, summary.json in Sync-statechart. ]); }); +test( + "--png exits 2 when a chart does not render, even with --expect-broken, and keeps the other files", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline"]; + assert.equal(node("statechart.mjs", args, dir).status, 0); + // An npx whose mermaid-cli cannot launch a headless browser. + const bin = mkdtempSync(join(tmpdir(), "no-browser-")); + const launch = "Error: Failed to launch the browser process!"; + writeFileSync(join(bin, "npx"), `#!/bin/sh\necho "${launch}" >&2\nexit 1\n`, { mode: 0o755 }); + const r = node("statechart.mjs", [...args, "--png"], dir, { + PATH: `${bin}${delimiter}${process.env.PATH}`, + }); + assert.equal(r.status, 2, r.stdout + r.stderr); + assert.equal( + r.stderr, + `could not render Sync-statechart/chart.mmd: ${launch}\ncould not render Sync-statechart/bug-1.mmd: ${launch}\n`, + ); + assert.match( + r.stdout, + /\nBroken as expected: NeverSyncedWhileOffline\.\n(.*\n)*Wrote chart\.mmd, bug-1\.mmd, stories\.md, summary\.json in Sync-statechart\.\n$/, + ); + assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ + "bug-1.mmd", + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); + }, +); + const pay = (name, charge) => `---- MODULE ${name} ---- EXTENDS Naturals VARIABLES screen, charges diff --git a/pstack/skills/statechart/scripts/statechart.mjs b/pstack/skills/statechart/scripts/statechart.mjs index b69911ae0..86ff7154a 100644 --- a/pstack/skills/statechart/scripts/statechart.mjs +++ b/pstack/skills/statechart/scripts/statechart.mjs @@ -131,7 +131,9 @@ Exit codes 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not CHART.mmd. The output says which. 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it - cannot read, no TLC, or a new mapping to fill in.`; + cannot read, no TLC, a new mapping to fill in, or a chart that --png cannot render. When a + chart does not render, the command keeps the files it wrote and exits 2, whatever the rules + give.`; const HELP = { "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} @@ -470,7 +472,7 @@ function finish(out, charts, files, ok) { const pngs = args.png ? render(charts) : []; const written = [...charts, ...pngs, ...files.slice(1)].map((f) => path.basename(f)).join(", "); console.log(`\nWrote ${written} in ${rel(out)}.`); - process.exit(ok ? 0 : 1); + process.exit(args.png && pngs.length < charts.length ? 2 : ok ? 0 : 1); } const outDir = (name) => { diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 545d76a23..362968a47 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { @@ -982,6 +982,40 @@ Wrote chart.mmd, stories.md, summary.json in Sync-statechart. ]); }); +test( + "--png exits 2 when a chart does not render, even with --expect-broken, and keeps the other files", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline"]; + assert.equal(node("statechart.mjs", args, dir).status, 0); + // An npx whose mermaid-cli cannot launch a headless browser. + const bin = mkdtempSync(join(tmpdir(), "no-browser-")); + const launch = "Error: Failed to launch the browser process!"; + writeFileSync(join(bin, "npx"), `#!/bin/sh\necho "${launch}" >&2\nexit 1\n`, { mode: 0o755 }); + const r = node("statechart.mjs", [...args, "--png"], dir, { + PATH: `${bin}${delimiter}${process.env.PATH}`, + }); + assert.equal(r.status, 2, r.stdout + r.stderr); + assert.equal( + r.stderr, + `could not render Sync-statechart/chart.mmd: ${launch}\ncould not render Sync-statechart/bug-1.mmd: ${launch}\n`, + ); + assert.match( + r.stdout, + /\nBroken as expected: NeverSyncedWhileOffline\.\n(.*\n)*Wrote chart\.mmd, bug-1\.mmd, stories\.md, summary\.json in Sync-statechart\.\n$/, + ); + assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ + "bug-1.mmd", + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); + }, +); + const pay = (name, charge) => `---- MODULE ${name} ---- EXTENDS Naturals VARIABLES screen, charges From b10886269e39e74540bb46ac5dd34aa5c97c8591 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 00:51:35 +0000 Subject: [PATCH 7/9] Update the statechart skill to its latest version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendered charts keep their full width. An edge label writes the opening words that its choices share once, instead of cutting the label with "…". --full-labels replaces --all-labels, and compare-charts refuses a chart drawn without it. The fix replay matches each bug step by the screens and step words that the reader sees, and two states get at most one arrow. Each rule also runs as a TLC CONSTRAINT, so a rule that stays broken stops at its first bad state instead of flooding TLC with counterexamples. statechart.mjs passes its arguments and models through functions instead of module globals. The --png exit code from the previous commit is now part of the source. Co-authored-by: lauren --- .../statechart/scripts/compare-charts.mjs | 17 +- .../skills/statechart/scripts/lib.mjs | 27 +- .../skills/statechart/scripts/statechart.mjs | 336 +++++++----- .../scripts/test/statechart.test.mjs | 486 ++++++++++++++++-- .../statechart/scripts/tlc-to-chart.mjs | 360 ++++++++----- .../statechart/scripts/compare-charts.mjs | 17 +- pstack/skills/statechart/scripts/lib.mjs | 27 +- .../skills/statechart/scripts/statechart.mjs | 336 +++++++----- .../scripts/test/statechart.test.mjs | 486 ++++++++++++++++-- .../statechart/scripts/tlc-to-chart.mjs | 360 ++++++++----- 10 files changed, 1826 insertions(+), 626 deletions(-) diff --git a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs index d3de630e7..3ddb3311a 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import fs from "node:fs"; import process from "node:process"; -import { LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; +import { FULL_LABELS, LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"; const HELP = `${USAGE} @@ -11,7 +11,7 @@ transitions. statechart.mjs from-chart runs it to check that the spec still draw the reader agreed to. EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer -view with --highlight 0 --all-labels --flat, without --cover, --fixed-dot, or --against-dot. Each +view with --highlight 0 --full-labels --flat, without --cover, --fixed-dot, or --against-dot. Each chart compares as the machine that its steps reach from its start states. A chart with parallel regions compares as its combined machine, so a guard that blocks a step in only some combinations of regions shows as a @@ -59,18 +59,24 @@ const orExit = (file, f) => { } }; const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8"))); +const has = (re) => re.test(actualText); const textRefusals = [ [ - /^\s*%% second model/m, + has(/^\s*%% second model/m), "it has a second model; draw ACTUAL without --fixed-dot or --against-dot", ], [ - new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m"), + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m")), "it has boxes; draw ACTUAL with --flat", ], + [ + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+${LEGEND_ID}\\s*\\{`, "m")) && + !actualText.includes(FULL_LABELS), + "edge labels write shared words once; draw ACTUAL with --full-labels", + ], ]; for (const [bad, why] of textRefusals) { - if (bad.test(actualText)) { + if (bad) { console.error(`${actualFile}: ${why}`); process.exit(2); } @@ -80,7 +86,6 @@ const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions) const drawn = Object.values(actualRegions).flatMap((r) => r.transitions); const refusals = [ - [(l) => / or …$/.test(l), 'an edge label ends in " or …"; draw ACTUAL with --all-labels'], [ (l) => /^\d+(, \d+)*\. /.test(l), "an edge label is a numbered path step; draw ACTUAL with --highlight 0", diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index 8fbac3be2..090c1a8d1 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -290,9 +290,27 @@ export function wrap(text, width = 26) { return rows.join("
"); } +// The shared words stop before a word that opens a phrase, so each choice keeps its whole phrase, +// as in "you tap the Pay button or the Back button". +const OPENS_A_PHRASE = new Set( + "a an the your my our their its this that to of in on at for with from by and or".split(" "), +); +export function joinLabels(labels) { + if (labels.length < 2) return labels.join(" or "); + const words = labels.map((l) => String(l).split(" ")); + let n = 0; + while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++; + while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--; + if (n < 2) return labels.join(" or "); + const rest = words.map((w) => w.slice(n).join(" ")); + const list = + rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`; + return `${words[0].slice(0, n).join(" ")} ${list}`; +} + const unwrap = (s) => s - .replace(//g, " ") + .replace(/|#160;/g, " ") .replace(/#(quot|34);/g, '"') .replace(/\s+/g, " ") .trim(); @@ -302,6 +320,8 @@ export const quote = (s) => String(s).replaceAll('"', "#quot;"); // The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; +// Marks a chart whose edge labels are written in full, which a chart comparison needs. +export const FULL_LABELS = "%% labels in full"; // Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label,guard?}] } }, nevers }. // Composite states flatten to "Parent.Child". A transition on a composite leaves from each state @@ -336,7 +356,8 @@ export function parseMermaid(text) { part().children.push({ name: id, leaf: true }); implicit.set(id, part()); }; - for (const raw of text.split("\n")) { + const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, ""); + for (const raw of body.split("\n")) { const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); if (when) { @@ -362,7 +383,7 @@ export function parseMermaid(text) { if ( !line || line.startsWith("%%") || - /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line) + /^(stateDiagram|direction|classDef|class |note)/.test(line) ) continue; let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs index 86ff7154a..ada61542f 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs @@ -19,9 +19,13 @@ const usage = (lines) => `Usage: ${lines.join("\n ")}`; const FROM_SPEC = `from-spec SPEC.tla Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state - breaks, and the separate runs find the rest. SPEC.cfg sits next to the spec and has SPECIFICATION - and one INVARIANT line per rule. One more run dumps the state graph, and then the command draws - the charts. + breaks, and the separate runs find the rest. Each run also has the rule as a CONSTRAINT, so TLC + stops each path at its first state that breaks the rule, and a rule that stays broken reports + only the step that broke it. SPEC.cfg sits next to the spec and has SPECIFICATION and one + INVARIANT line per rule. One more run dumps the state graph, and then the command draws the + charts. TLC names each step after the innermost named action that takes it, so give each kind of + step its own named action. The command warns when steps carry the name of the whole next-state + action. --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks @@ -106,14 +110,19 @@ Reading a chart When the mapping exports group(), the fill is the state's group, which the legend names, and the status is a thick red or green border on top. tlc-to-chart.mjs --help tells more. A state is bad when it breaks a rule, or when every path from the start to it passes a state - that breaks one. + that breaks one. TLC stops each path at the first state that breaks the rule, so a rule that + stays broken colors only the states where it first breaks and the states after them that no + path without a bug reaches. A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an edge. - An edge labeled "with the fix" or "in the spec" leaves the state before the step that the other - model changes, and ends where that model goes instead. "blocked by the fix" or "not in the spec" - loops on the state where that model stops the step. "(with the fix)" or "(in the spec)" marks an - edge that only the other model has. - An edge shows its two most common labels. "or …" means that there are more. + A label "with the fix, STEP" or "in the spec, STEP" is a step that only the other model takes. On + a bug's path it leaves the state before the first step that the other model shows the reader + differently, and ends where that model goes instead. "with the fix, STEP cannot happen" or "in + the spec, STEP cannot happen" loops on the state where that model stops the step. Outside + regions, two states have at most one arrow between them. Its lines are the numbered steps, then + SPEC.tla's steps, then the other model's steps, with a blank line between each group. + An edge lists every step that takes it, most common first. Words that the steps share at the + start are written once, as in "you tap Pay or Back". Regenerate With --expect-broken, one script rebuilds every chart and stops on any change: @@ -150,69 +159,64 @@ ${COMMON}`, "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`, }; -function fail(message) { - console.error(message); - process.exit(2); -} - -const [command, ...rest] = process.argv.slice(2); const more = (cmd) => `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; -if (command === "-h" || command === "--help") { - console.log(HELP[""]); - process.exit(0); -} -const FLAGS = { - "from-spec": ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], - "from-chart": ["--module", "--expect-broken", "--title", "--out"], + +class CannotFinish extends Error {} + +const COMMANDS = { + "from-spec": { + flags: ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + run: fromSpec, + }, + "from-chart": { flags: ["--module", "--expect-broken", "--title", "--out"], run: fromChart }, }; -if (!FLAGS[command]) fail(`${usage(Object.values(USAGE))}\n${more()}`); -const args = { "expect-broken": [] }; -let input; -for (let i = 0; i < rest.length; i++) { - const a = rest[i]; - if (a === "-h" || a === "--help") { - console.log(HELP[command]); - process.exit(0); - } else if (a === "--png") args.png = true; - else if (FLAGS[command].includes(a)) { - if (i + 1 === rest.length) fail(`${a} needs a value. ${more(command)}`); - const name = a.slice(2); - if (Array.isArray(args[name])) args[name].push(rest[++i]); - else args[name] = rest[++i]; - } else if (!a.startsWith("-") && input === undefined) input = a; - else fail(`unknown argument ${a}. ${more(command)}`); + +function parseArgs([command, ...rest]) { + if (command === "-h" || command === "--help") return { help: HELP[""] }; + if (!Object.hasOwn(COMMANDS, command)) + throw new CannotFinish(`${usage(Object.values(USAGE))}\n${more()}`); + const flags = { "expect-broken": new Set() }; + let input; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "-h" || a === "--help") return { help: HELP[command] }; + if (a === "--png") flags.png = true; + else if (COMMANDS[command].flags.includes(a)) { + if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`); + const name = a.slice(2); + if (flags[name] instanceof Set) flags[name].add(rest[++i]); + else flags[name] = rest[++i]; + } else if (!a.startsWith("-") && input === undefined) input = a; + else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`); + } + if (input === undefined) throw new CannotFinish(`${usage([USAGE[command]])}\n${more(command)}`); + return { command, input, flags }; } -if (input === undefined) fail(`${usage([USAGE[command]])}\n${more(command)}`); -if (args.fixed && args.against) fail(`use --fixed or --against, not both. ${more(command)}`); -if (args.label !== undefined && !args.fixed && !args.against) - fail(`--label names the --fixed or --against model; it needs one of them. ${more(command)}`); -const expected = new Set(args["expect-broken"]); const rel = (file) => path.relative(process.cwd(), file) || "."; -let tools; -function needTlc() { +const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg"); + +function findTools() { try { - tools = findTlc(); + return findTlc(); } catch (e) { - fail(e.message); + throw new CannotFinish(e.message); } } -// The child scripts throw on input they cannot use. Print the message and the file it is about, not the stack. -function script(name, scriptArgs, about) { +function script(name, scriptArgs, about, { okExits = [0] } = {}) { const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { encoding: "utf8", }); - if (r.status === 0 || (name === "compare-charts.mjs" && r.status === 1)) return r; + if (okExits.includes(r.status)) return r; const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; - fail(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); + throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); } const KEYWORD = /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/; -// The config without its INVARIANT sections, and the rules they name. function splitRules(cfg) { const words = cfg .replace(/\(\*[^]*?\*\)/g, " ") @@ -227,10 +231,10 @@ function splitRules(cfg) { if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); else if (w !== section && !rules.includes(w)) rules.push(w); } - return { base: `${kept.join(" ")}\n`, rules }; + return { cfgWithoutRules: `${kept.join(" ")}\n`, rules }; } -function tlc(spec, cfg, base, dump) { +function tlc(tools, spec, cfg, base, dump) { fs.writeFileSync(`${base}.cfg`, cfg); const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. @@ -252,36 +256,67 @@ function tlc(spec, cfg, base, dump) { { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 }, ); fs.rmSync(metadir, { recursive: true, force: true }); - if (r.error) fail(`cannot run TLC: ${r.error.message}`); + if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); fs.writeFileSync(`${base}.out`, r.stdout); try { checkTlcOutput(r.stdout, rel(`${base}.out`)); } catch (e) { - fail(e.message); + throw new CannotFinish(e.message); } return r.stdout; } -// main is true for the input spec: --expect-broken names its rules. -function check(spec, cfgFile, tlcDir, main) { - const name = path.basename(spec, ".tla"); - if (!fs.existsSync(spec)) fail(`${rel(spec)} does not exist`); +function warnUnnamedSteps(spec, cfg, dot) { + const text = fs + .readFileSync(spec, "utf8") + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " "); + const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1]; + const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text); + const next = + /^NEXT\s+(\w+)/m.exec(cfg)?.[1] ?? /\[\]\s*\[\s*(\w+)\s*\]_/.exec(specBody?.[1] ?? text)?.[1]; + const graph = parseDot(fs.readFileSync(dot, "utf8")); + const unnamed = [...graph.edges, ...graph.loops].filter((e) => e.action === next).length; + if (unnamed > 0) + console.error( + `${rel(spec)}: ${unnamed} ${unnamed === 1 ? "step has" : "steps have"} the name ${next}, the whole next-state action, because TLC names a step after the innermost named action that takes it. If the reader must tell these steps apart, give each part of ${next} its own name, as in Pay == ... and ${next} == Pay \\/ Charge.`, + ); +} + +function readModel(spec, cfgFile) { + if (!fs.existsSync(spec)) throw new CannotFinish(`${rel(spec)} does not exist`); if (!fs.existsSync(cfgFile)) - fail(`${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`); - const { base, rules } = splitRules(fs.readFileSync(cfgFile, "utf8")); - const unknown = main ? [...expected].filter((r) => !rules.includes(r)) : []; + throw new CannotFinish( + `${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`, + ); + return { spec, cfgFile, ...splitRules(fs.readFileSync(cfgFile, "utf8")) }; +} + +function readInput(spec, expected) { + const model = readModel(spec, cfgOf(spec)); + const unknown = [...expected].filter((r) => !model.rules.includes(r)); if (unknown.length) - fail( - `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(cfgFile)}, whose rules are ${rules.join(", ") || "none"}.`, + throw new CannotFinish( + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${model.rules.join(", ") || "none"}.`, ); + return model; +} + +function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, out, expected) { + const name = path.basename(spec, ".tla"); + const tlcDir = path.join(out, "tlc"); const dot = path.join(tlcDir, `${name}.dot`); - tlc(spec, base, path.join(tlcDir, name), dot); + tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); + warnUnnamedSteps(spec, cfgWithoutRules, dot); console.log(`TLC on ${path.basename(spec)}:`); if (rules.length === 0) console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); + // With -continue TLC reports every bad state. The CONSTRAINT stops each path at its first bad + // state, so a rule that stays broken reports only the step that broke it, not every later state. const traces = rules.map((rule) => { const file = path.join(tlcDir, `${name}-${rule}`); - const broken = /^Error: Invariant /m.test(tlc(spec, `${base}INVARIANT ${rule}\n`, file)); + const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`; + const broken = /^Error: Invariant /m.test(tlc(tools, spec, cfg, file)); console.log( ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, ); @@ -290,9 +325,7 @@ function check(spec, cfgFile, tlcDir, main) { return { spec, dot, traces, broken: traces.some((t) => t.broken) }; } -// Without --expect-broken, every rule must hold. With it, exactly those rules break in the input -// spec, and the other model breaks no other rule. -function rulesAsExpected(now, other) { +function rulesAsExpected(now, other, expected) { const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); if (expected.size === 0) return !now.broken && !other?.broken; const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( @@ -357,31 +390,37 @@ export function step(action, before, after) { // Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and // the app drops an upload it cannot send". No variable, function, or event names. // Counterexamples that break one rule with one cause are one bug, so two bugs that end on the -// same screen stay apart only if their causes differ. +// same screen stay apart only if their causes differ. Return null to group by the end state. // export function cause(v, facts, invariant) {} // The developer's words for one model state. With it, each bug is also drawn through these states. // export function detail(v, facts) {} + +// The legend's words for each status, in place of the defaults. Group entries keep their names. +// export const legend = { works: "Works on main", wrong: "Wrong on main", fixed: "Fixed in the open PR" }; `; }; -async function draw({ now, second, role, map, out, comparing }) { +async function draw({ now, other, map, out, title, comparing }) { const mapping = await import(pathToFileURL(path.resolve(map)).href); const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; from.push(...now.traces.flatMap((t) => ["--traces", t.file])); - if (second) { - from.push(`--${role}-dot`, second.dot, "--spec", second.spec); - from.push(...second.traces.flatMap((t) => [`--${role}-traces`, t.file])); - if (args.label !== undefined) from.push("--label", args.label); + if (other) { + from.push(`--${other.role}-dot`, other.dot, "--spec", other.spec); + from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file])); + if (other.label !== undefined) from.push("--label", other.label); } - // compare-charts.mjs needs every edge, every label, and no boxes, and --cover drops edges. - if (comparing) from.push("--all-labels", "--flat"); + if (comparing) from.push("--full-labels", "--flat"); else if (mapping.readerActions?.length) from.push("--cover"); - const titled = (suffix) => (args.title ? ["--title", `${args.title}${suffix}`] : []); + const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []); for (const f of fs.readdirSync(out)) if (/^bug-\d+(-developer|-layered)?\.(mmd|png)$/.test(f)) fs.rmSync(path.join(out, f)); - const files = ["chart.mmd", "stories.md", "summary.json"].map((f) => path.join(out, f)); + const files = { + overview: path.join(out, "chart.mmd"), + stories: path.join(out, "stories.md"), + summary: path.join(out, "summary.json"), + }; const proof = path.join(out, "tlc", "regions-proof.mmd"); const everyArrow = path.join(out, "tlc", "regions-all.mmd"); for (const f of [proof, everyArrow]) fs.rmSync(f, { force: true }); @@ -394,16 +433,16 @@ async function draw({ now, second, role, map, out, comparing }) { "--highlight", "0", "--out", - files[0], + files.overview, "--story", - files[1], + files.stories, "--summary", - files[2], + files.summary, ], map, ); - const summary = JSON.parse(fs.readFileSync(files[2], "utf8")); - const charts = [files[0]]; + const summary = JSON.parse(fs.readFileSync(files.summary, "utf8")); + const charts = [files.overview]; for (const c of summary.classes) { const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; for (const view of views) { @@ -431,9 +470,9 @@ async function draw({ now, second, role, map, out, comparing }) { else if (r) console.log(`It has no parallel regions, because ${r.why}.`); const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; console.log( - role === "fixed" + other?.role === "fixed" ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n` - : role === "against" + : other?.role === "against" ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n` : `${works} work, ${breaks}.\n`, ); @@ -442,22 +481,23 @@ async function draw({ now, second, role, map, out, comparing }) { `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`, ); console.log( - summary.classes.length ? fs.readFileSync(files[1], "utf8").trimEnd() : "No rule is broken.", + summary.classes.length + ? fs.readFileSync(files.stories, "utf8").trimEnd() + : "No rule is broken.", ); return { files, charts }; } function render(charts) { - // Mermaid shrinks a wide chart to the 800px page; keep its natural size so labels stay readable. - const config = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")), "mermaid.json"); - fs.writeFileSync(config, JSON.stringify({ state: { useMaxWidth: false } })); const pngs = []; for (const chart of charts) { const png = chart.replace(/\.mmd$/, ".png"); const r = spawnSync( "npx", - ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2", "-c", config], - { encoding: "utf8" }, + ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2"], + { + encoding: "utf8", + }, ); if (r.status === 0) pngs.push(png); else @@ -468,55 +508,70 @@ function render(charts) { return pngs; } -function finish(out, charts, files, ok) { - const pngs = args.png ? render(charts) : []; - const written = [...charts, ...pngs, ...files.slice(1)].map((f) => path.basename(f)).join(", "); +function finish(out, { files, charts }, ok, png) { + const pngs = png ? render(charts) : []; + const written = [...charts, ...pngs, files.stories, files.summary] + .map((f) => path.basename(f)) + .join(", "); console.log(`\nWrote ${written} in ${rel(out)}.`); - process.exit(args.png && pngs.length < charts.length ? 2 : ok ? 0 : 1); + if (png && pngs.length < charts.length) return 2; + return ok ? 0 : 1; } -const outDir = (name) => { - const out = args.out ?? path.join(path.dirname(input), `${name}-statechart`); +function outDir(flagged, input, name) { + const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`); fs.mkdirSync(path.join(out, "tlc"), { recursive: true }); return out; -}; +} -if (command === "from-spec") { +function secondModel(input, { fixed, against, label }) { + if (fixed && against) + throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); + if (!fixed && !against) { + if (label === undefined) return null; + throw new CannotFinish( + `--label names the --fixed or --against model; it needs one of them. ${more("from-spec")}`, + ); + } + const [role, spec] = fixed ? ["fixed", fixed] : ["against", against]; const name = path.basename(input, ".tla"); - const role = args.fixed ? "fixed" : args.against ? "against" : null; - const other = args.fixed ?? args.against; // TLC's output files are named after the spec, so a second model with the same name overwrites them. - if (other && path.basename(other, ".tla") === name) - fail( - `${rel(other)} needs its own file and module name, such as ${name}${role === "fixed" ? "Fixed" : "Agreed"}.tla.`, + if (path.basename(spec, ".tla") === name) + throw new CannotFinish( + `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, ); - needTlc(); - const out = outDir(name); - const map = args.map ?? path.join(path.dirname(input), `${name}.map.mjs`); - const now = check(input, input.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); + return { role, spec, label }; +} + +async function fromSpec(input, { "expect-broken": expected, ...flags }) { + const second = secondModel(input, flags); + const tools = findTools(); + const name = path.basename(input, ".tla"); + const out = outDir(flags.out, input, name); + const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const now = check(tools, readInput(input, expected), out, expected); if (!fs.existsSync(map)) { fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); - fail(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); + throw new CannotFinish(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); } - let second = null; - if (other) { - const cfg = other.replace(/\.tla$/, ".cfg"); - second = check( - other, - fs.existsSync(cfg) ? cfg : input.replace(/\.tla$/, ".cfg"), - path.join(out, "tlc"), - ); + let other = null; + if (second) { + const { role, spec, label } = second; + const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); + other = { role, label, ...check(tools, readModel(spec, cfg), out, expected) }; } - const { files, charts } = await draw({ now, second, role, map, out }); - finish(out, charts, files, rulesAsExpected(now, second)); -} else { + const drawn = await draw({ now, other, map, out, title: flags.title }); + return finish(out, drawn, rulesAsExpected(now, other, expected), flags.png); +} + +async function fromChart(input, { "expect-broken": expected, ...flags }) { const name = - args.module ?? + flags.module ?? path .basename(input, path.extname(input)) .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase()); if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) - fail(`cannot make a TLA+ module name from ${input}; add --module NAME`); + throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`); const spec = path.join(path.dirname(input), `${name}.tla`); if (fs.existsSync(spec)) { console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); @@ -524,20 +579,41 @@ if (command === "from-spec") { script("chart-to-tla.mjs", [input, "--module", name], input); console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); } - needTlc(); - const out = outDir(name); - const now = check(spec, spec.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); - const { files, charts } = await draw({ + const tools = findTools(); + const out = outDir(flags.out, input, name); + const now = check(tools, readInput(spec, expected), out, expected); + const drawn = await draw({ now, map: spec.replace(/\.tla$/, ".map.mjs"), out, + title: flags.title, comparing: true, }); - const compared = script("compare-charts.mjs", [input, files[0]]); + const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { + okExits: [0, 1], + }); if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); else console.log( `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, ); - finish(out, charts, files, rulesAsExpected(now) && compared.status === 0); + return finish( + out, + drawn, + rulesAsExpected(now, null, expected) && compared.status === 0, + flags.png, + ); +} + +try { + const { help, command, input, flags } = parseArgs(process.argv.slice(2)); + if (help) { + console.log(help); + process.exit(0); + } + process.exit(await COMMANDS[command].run(input, flags)); +} catch (e) { + if (!(e instanceof CannotFinish)) throw e; + console.error(e.message); + process.exit(2); } diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 362968a47..79618ba78 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -1,5 +1,3 @@ -// node --test scripts/test/statechart.test.mjs -// The TLC tests run when statechart.mjs can find TLC. Its --help says where it looks. import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -27,6 +25,7 @@ const node = (script, args, cwd, env = {}) => cwd, encoding: "utf8", env: { ...process.env, ...env }, + timeout: 120_000, }); test("a transition on a composite leaves from every state inside it", () => { @@ -592,7 +591,6 @@ test("compare refuses a chart it cannot read, or an ACTUAL chart that is not dra assert.match(r.stderr, /^missing\.mmd: ENOENT/); } for (const [extra, why] of [ - [" U_O --> L_O : tap lock or …", /--all-labels/], [" U_O --> L_O : 1. tap lock", /--highlight 0/], [ " %% second model (against): the spec", @@ -611,6 +609,96 @@ test("compare refuses a chart it cannot read, or an ACTUAL chart that is not dra } }); +test("an edge lists every step, writes shared leading words once, and the chart renders at its natural width", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const nodes = [ + ["Home", 1], + ["Home", 2], + ["Home", 3], + ["Away", 1], + ["Away", 2], + ["Done", 1], + ].map( + ([s, n], i) => + `${i + 1} [label="/\\\\ s = \\"${s}\\"\\n/\\\\ n = ${n}"${i ? "" : ",style = filled"}]`, + ); + const steps = [ + "1 -> 4 TapPay", + "2 -> 5 TapBack", + "3 -> 4 TapHome", + "4 -> 1 OpenThread", + "5 -> 2 OpenApp", + "4 -> 6 Finish", + ]; + writeFileSync( + join(dir, "nav.dot"), + `strict digraph DiskGraph {\n${nodes.join("\n")}\n${steps.map((e) => e.replace(/ (\w+)$/, ' [label="$1"];')).join("\n")}\n}\n`, + ); + writeFileSync( + join(dir, "nav.map.mjs"), + `export const state = (v) => v.s; +export const step = (a) => ({ TapPay: "you tap Pay", TapBack: "you tap Back", TapHome: "you tap Home", OpenThread: "you open the thread", OpenApp: "you open the phone app", Finish: "" })[a]; +`, + ); + writeFileSync( + join(dir, "agreed.mmd"), + `stateDiagram-v2 + [*] --> Home + Home --> Away : you tap Pay + Home --> Away : you tap Back + Home --> Away : you tap Home + Away --> Home : you open the thread + Away --> Home : you open the phone app + Away --> Done +`, + ); + const draw = (...extra) => { + const r = node( + "tlc-to-chart.mjs", + [ + "--dot", + "nav.dot", + "--map", + "nav.map.mjs", + "--highlight", + "0", + "--out", + "chart.mmd", + ...extra, + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, "chart.mmd"), "utf8"); + }; + const chart = draw(); + assert.ok( + chart.startsWith( + "---\nconfig:\n markdownAutoWrap: false\n state:\n useMaxWidth: false\n---\nstateDiagram-v2\n", + ), + chart, + ); + const shortened = [ + "Away -> Done : ", + "Away -> Home : you open the thread or the phone app", + "Home -> Away : you tap Pay, Back, or Home", + ]; + assert.deepEqual(edges(parseMermaid(chart).regions.Main), shortened); + const crlf = chart.replace("---\n", "--- \n").replaceAll("\n", "\r\n"); + assert.deepEqual(edges(parseMermaid(crlf).regions.Main), shortened); + const refused = node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir); + assert.equal(refused.status, 2); + assert.match(refused.stderr, /draw ACTUAL with --full-labels/); + const full = draw("--full-labels"); + assert.match(full, /^stateDiagram-v2\n {2}%% labels in full\n/m); + assert.deepEqual(edges(parseMermaid(full).regions.Main), [ + "Away -> Done : ", + "Away -> Home : you open the thread or you open the phone app", + "Home -> Away : you tap Pay or you tap Back or you tap Home", + ]); + assert.equal(node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir).stdout, "same\n"); +}); + const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: https://github.com/tlaplus/tlaplus/releases Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). @@ -795,7 +883,6 @@ const tools = (() => { })(); const needsTlc = { skip: tools ? false : "cannot find TLC" }; -// Check every INVARIANT in NAME.cfg in one run and dump NAME.dot. function tlc(dir, name) { const r = spawnSync( tools.java, @@ -909,7 +996,7 @@ Chart: 4 states and 9 transitions from 4 model states. 3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. ${syncStory} -With the fix, step 2 cannot happen, so you stay at: Offline | Waiting to sync. +With the fix, step 2 (the server confirms) cannot happen, so you stay at: Offline | Waiting to sync. Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. `, @@ -983,36 +1070,56 @@ Wrote chart.mmd, stories.md, summary.json in Sync-statechart. }); test( - "--png exits 2 when a chart does not render, even with --expect-broken, and keeps the other files", + "--png exits 2 unless every chart renders, even with --expect-broken, and keeps the files it wrote", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); - const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline"]; - assert.equal(node("statechart.mjs", args, dir).status, 0); - // An npx whose mermaid-cli cannot launch a headless browser. - const bin = mkdtempSync(join(tmpdir(), "no-browser-")); + const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline", "--png"]; + const bin = mkdtempSync(join(tmpdir(), "fake-npx-")); const launch = "Error: Failed to launch the browser process!"; - writeFileSync(join(bin, "npx"), `#!/bin/sh\necho "${launch}" >&2\nexit 1\n`, { mode: 0o755 }); - const r = node("statechart.mjs", [...args, "--png"], dir, { - PATH: `${bin}${delimiter}${process.env.PATH}`, - }); - assert.equal(r.status, 2, r.stdout + r.stderr); - assert.equal( - r.stderr, - `could not render Sync-statechart/chart.mmd: ${launch}\ncould not render Sync-statechart/bug-1.mmd: ${launch}\n`, - ); - assert.match( - r.stdout, - /\nBroken as expected: NeverSyncedWhileOffline\.\n(.*\n)*Wrote chart\.mmd, bug-1\.mmd, stories\.md, summary\.json in Sync-statechart\.\n$/, + writeFileSync( + join(bin, "npx"), + `#!/bin/sh +while [ $# -gt 0 ]; do + case "$1" in -i) chart=$2 ;; -o) png=$2 ;; esac + shift +done +for c in $UNRENDERED; do + case "$chart" in */$c.mmd) echo "${launch}" >&2; exit 1 ;; esac +done +echo png > "$png" +`, + { mode: 0o755 }, ); - assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ - "bug-1.mmd", - "chart.mmd", - "stories.md", - "summary.json", - "tlc", - ]); + for (const [unrendered, status, written] of [ + ["chart bug-1", 2, ["chart.mmd", "bug-1.mmd"]], + ["bug-1", 2, ["chart.mmd", "bug-1.mmd", "chart.png"]], + ["", 0, ["chart.mmd", "bug-1.mmd", "chart.png", "bug-1.png"]], + ]) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("statechart.mjs", args, dir, { + PATH: `${bin}${delimiter}${process.env.PATH}`, + UNRENDERED: unrendered, + }); + assert.equal(r.status, status, `${unrendered}: ${r.stdout}${r.stderr}`); + assert.equal( + r.stderr, + unrendered + .split(" ") + .filter(Boolean) + .map((c) => `could not render Sync-statechart/${c}.mmd: ${launch}\n`) + .join(""), + ); + assert.match(r.stdout, /\nBroken as expected: NeverSyncedWhileOffline\.\n/); + assert.equal( + r.stdout.split("\n").at(-2), + `Wrote ${[...written, "stories.md", "summary.json"].join(", ")} in Sync-statechart.`, + ); + assert.deepEqual( + readdirSync(join(dir, "Sync-statechart")).sort(), + [...written, "stories.md", "summary.json", "tlc"].sort(), + ); + } }, ); @@ -1108,13 +1215,13 @@ test( [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), ["Works today", "Fixed or new with the fix"], ); - assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/m); const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => - t.label.endsWith("with the fix"), + t.label.startsWith("with the fix, "), ); assert.deepEqual( fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), - ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], + ["Paying, charged --> Paid, charged : with the fix, the bank charges you"], ); }, ); @@ -1154,7 +1261,7 @@ test( ); assert.equal( readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), - "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n", + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", ); writeFileSync( @@ -1326,9 +1433,7 @@ test( }, ); -// Each edge as "from --> to : label" with full state labels, and an arrow from a box's border -// expanded to one edge from each state in the box. -function chartEdges(mmd) { +function flatEdges(mmd) { const names = new Map(); const members = new Map(); let box = null; @@ -1402,7 +1507,7 @@ export const step = (a) => ({ Boot: "the app starts", Ask: "you ask", Answer: "t ); assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3); assert.doesNotMatch(flat, /as g\d+ \{/); - assert.deepEqual(chartEdges(boxed), chartEdges(flat)); + assert.deepEqual(flatEdges(boxed), flatEdges(flat)); assert.match(boxed, /^ {2}class s5 wrongPath$/m); assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m); assert.match(boxed, /state "Breaks a rule" as legendWrong/); @@ -1495,7 +1600,7 @@ There are 2 ways to get here in the model. This is the shortest. 1. You press Save. Now: Saving, draft 2. The save times out. Now: Saved, draft -In the agreed chart, step 2 cannot happen, so you stay at: Saving, draft. +In the agreed chart, step 2 (the save times out) cannot happen, so you stay at: Saving, draft. Broken as expected: NoFalseSaved. @@ -1504,12 +1609,15 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. ); const out = join(dir, "SaveCode-statechart"); const chart = readFileSync(join(out, "chart.mmd"), "utf8"); - assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m); assert.deepEqual( [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), ["Works", "Breaks a rule"], ); - assert.match(chart, /^ {2}s3 --> s3 : the save times out, not in theagreed chart$/m); + assert.match( + chart, + /^ {2}s3 --> s3 : in the agreed chart, the savetimes out cannot happen$/m, + ); const bug = readFileSync(join(out, "bug-1.mmd"), "utf8"); assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [ "Editing, draft", @@ -1586,7 +1694,6 @@ export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`; export const step = (a) => ({ Ask: "you ask", Answer: "the reply arrives", Drop: "the app drops the reply", Close: "you close the window", Reopen: "you open the window" })[a]; `; -// Checks Door.tla with one rule, which breaks, and returns what from-spec printed and drew. function runDoor(map) { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "Door.tla"), doorSpec); @@ -1721,6 +1828,12 @@ test( const run = runDoor(groupMap)("NoLostReply"); const lines = run.chart.split("\n"); assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [ + "---", + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", "stateDiagram-v2", " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", @@ -1845,10 +1958,11 @@ export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); assert.deepEqual( coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)), [ + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", " class legendWrong plainWrong", " class s0 plainWrong", - " class s1 plainWrong", + " class s1 plain", ], ); const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8")); @@ -1882,3 +1996,287 @@ export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); } }, ); + +const order = (name, note, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges, note +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ note = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ note' = ${note} /\\ UNCHANGED charges +Charge == screen = "Paying" /\\ charges < 2 /\\ ${charge} +Next == Pay \\/ Charge +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const chargeAgain = "charges' = charges + 1 /\\ UNCHANGED <>"; +const chargeOnce = `IF charges = 0 THEN charges' = 1 /\\ UNCHANGED <> ELSE screen' = "Paid" /\\ UNCHANGED <>`; +const chargeFirstOnly = "charges = 0 /\\ charges' = 1 /\\ UNCHANGED <>"; + +test( + "the fix changes a bug only at the first step that the reader sees differently", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Order.tla"), order("Order", 0, chargeAgain)); + writeFileSync(join(dir, "Order.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"); + const withFix = (charge, map = payMap) => { + writeFileSync(join(dir, "Order.map.mjs"), map); + writeFileSync(join(dir, "OrderFixed.tla"), order("OrderFixed", 1, charge)); + const r = node( + "statechart.mjs", + ["from-spec", "Order.tla", "--fixed", "OrderFixed.tla"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const chart = readFileSync(join(dir, "Order-statechart", "chart.mmd"), "utf8"); + return { + story: readFileSync(join(dir, "Order-statechart", "stories.md"), "utf8"), + fixEdges: parseMermaid(chart) + .regions.Main.transitions.filter((t) => t.label.includes("with the fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`), + }; + }; + const hidden = withFix(chargeAgain); + assert.deepEqual(hidden.fixEdges, []); + assert.match( + hidden.story, + /^With the fix you see the same screens and steps, so the fix does not change this path\.$/m, + ); + const once = withFix(chargeOnce); + assert.deepEqual(once.fixEdges, [ + "Paying, charged --> Paid, charged : with the fix, the bank charges you", + ]); + assert.match( + once.story, + /^With the fix, step 3 \(the bank charges you\) takes you to: Paid, charged\.$/m, + ); + const unlabeledMap = payMap.replace( + /^export const state = .*$/m, + "export const state = (v) => v.screen;", + ); + const unseen = withFix(chargeOnce, unlabeledMap); + assert.deepEqual(unseen.fixEdges, ["Paying --> Paid : with the fix, the bank charges you"]); + assert.match( + unseen.story, + /^With the fix, after step 1, "the bank charges you" happens and takes you to: Paid\.$/m, + ); + const blocked = withFix(chargeFirstOnly, unlabeledMap); + assert.deepEqual(blocked.fixEdges, []); + assert.match( + blocked.story, + /yet the rule holds, so the state labels do not show what the fix changes/, + ); + }, +); + +const notify = (name, mutedArrive) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, muted, banner, badge, missed, msgs, syncs +vars == <> +Init == screen = "Inbox" /\\ muted = FALSE /\\ banner = FALSE /\\ badge = FALSE + /\\ missed = FALSE /\\ msgs = 0 /\\ syncs = 0 +OpenThread == screen = "Inbox" /\\ screen' = "Thread" /\\ UNCHANGED <> +OpenSettings == screen \\in {"Inbox", "Thread"} /\\ screen' = "Settings" /\\ UNCHANGED <> +TapBack == screen \\in {"Thread", "Settings"} /\\ screen' = "Inbox" /\\ UNCHANGED <> +Mute == screen = "Settings" /\\ ~muted /\\ muted' = TRUE /\\ UNCHANGED <> +Refresh == syncs < 2 /\\ syncs' = syncs + 1 /\\ UNCHANGED <> +Arrive == msgs < syncs /\\ msgs' = msgs + 1 + /\\ (IF muted THEN ${mutedArrive} ELSE banner' = TRUE /\\ UNCHANGED <>) + /\\ UNCHANGED <> +Next == OpenThread \\/ OpenSettings \\/ TapBack \\/ Mute \\/ Refresh \\/ Arrive +Spec == Init /\\ [][Next]_vars +NeverMissed == ~missed +NoBannerOnThread == ~(banner /\\ screen = "Thread") +==== +`; + +test( + "a step the reader does not see breaks the rule, and the fix shows it on another screen", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Notify.tla"), + notify("Notify", "missed' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "NotifyBadge.tla"), + notify("NotifyBadge", "badge' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "Notify.cfg"), + "SPECIFICATION Spec\nINVARIANT NeverMissed\nINVARIANT NoBannerOnThread\n", + ); + writeFileSync( + join(dir, "Notify.map.mjs"), + `export const readerActions = ["OpenThread", "OpenSettings", "TapBack", "Mute", "Refresh"]; +export const state = (v) => [v.screen, v.muted && "muted", v.banner && "banner", v.badge && "badge"].filter(Boolean).join(", "); +export const step = (a) => ({ OpenThread: "you open the thread", OpenSettings: "you open settings", TapBack: "you tap Back", Mute: "you mute alerts", Refresh: "you pull to refresh", Arrive: "a message arrives" })[a]; +export const cause = () => null; +`, + ); + const r = node( + "statechart.mjs", + ["from-spec", "Notify.tla", "--fixed", "NotifyBadge.tla", "--label", "the badge fix"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Notify-statechart", f), "utf8"); + const fixEdges = (f) => + parseMermaid(out(f)) + .regions.Main.transitions.filter((t) => t.label.includes("with the badge fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`); + const { classes } = JSON.parse(out("summary.json")); + assert.deepEqual( + classes.map((c) => [c.invariant, c.end, c.fix]), + [ + ["NoBannerOnThread", "Thread, banner", "unchanged"], + ["NeverMissed", "Settings, muted", "Settings, muted, badge"], + ["NeverMissed", "Inbox, muted", "Inbox, muted, badge"], + ["NeverMissed", "Settings, muted, banner", "Settings, muted, banner, badge"], + ["NeverMissed", "Thread, muted", "Thread, muted, badge"], + ["NoBannerOnThread", "Thread, muted, banner", "unchanged"], + ["NeverMissed", "Inbox, muted, banner", "Inbox, muted, banner, badge"], + ["NeverMissed", "Thread, muted, banner", "Thread, muted, banner, badge"], + ["NoBannerOnThread", "Thread, muted, banner, badge", "new with the badge fix"], + ], + ); + const missed = classes.filter((c) => c.invariant === "NeverMissed"); + + const story = out("stories.md"); + assert.doesNotMatch(story, /do not show what/); + assert.match( + story, + /^With the badge fix, after step 3, "a message arrives" happens and takes you to: Settings, muted, badge\.$/m, + ); + const settings = classes.find((c) => c.end === "Settings, muted"); + assert.deepEqual(fixEdges(`bug-${settings.n}.mmd`), [ + "Settings, muted --> Settings, muted, badge : with the badge fix, a message arrives", + ]); + const drawn = fixEdges("chart.mmd"); + assert.equal(new Set(drawn).size, drawn.length); + for (const c of missed) + assert.ok( + drawn.includes(`${c.end} --> ${c.end}, badge : with the badge fix, a message arrives`), + `bug ${c.n}`, + ); + }, +); + +test( + "each looping step on a bug path keeps its own label, a null cause groups bugs by end state, and the mapping names the key's colors", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Browse.tla"), + `---- MODULE Browse ---- +EXTENDS Naturals +VARIABLES screen, looks, scrolled +vars == <> +Init == screen = "Cart" /\\ looks = 0 /\\ scrolled = FALSE +Look == screen = "Cart" /\\ looks < 3 /\\ looks' = looks + 1 /\\ UNCHANGED <> +Scroll == screen = "Cart" /\\ looks = 1 /\\ ~scrolled /\\ scrolled' = TRUE /\\ UNCHANGED <> +Pay == screen = "Cart" /\\ looks = 3 /\\ scrolled /\\ screen' = "Paying" /\\ UNCHANGED <> +Fail == screen = "Paying" /\\ screen' = "Failed" /\\ UNCHANGED <> +Hang == screen = "Paying" /\\ screen' = "Stuck" /\\ UNCHANGED <> +Next == Look \\/ Scroll \\/ Pay \\/ Fail \\/ Hang +Spec == Init /\\ [][Next]_vars +NeverLost == screen \\notin {"Failed", "Stuck"} +==== +`, + ); + writeFileSync(join(dir, "Browse.cfg"), "SPECIFICATION Spec\nINVARIANT NeverLost\n"); + writeFileSync( + join(dir, "Browse.map.mjs"), + `export const readerActions = ["Look", "Scroll", "Pay"]; +export const state = (v) => v.screen; +export const step = (a) => ({ Look: "you look", Scroll: "you scroll", Pay: "you pay", Fail: "the payment fails", Hang: "the payment hangs" })[a]; +export const cause = () => null; +export const legend = { works: "Works on main", wrong: "Wrong on main" }; +`, + ); + const r = node("statechart.mjs", ["from-spec", "Browse.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Browse-statechart", f), "utf8"); + assert.deepEqual( + JSON.parse(out("summary.json")).classes.map((c) => [c.end, c.cause]), + [ + ["Failed", null], + ["Stuck", null], + ], + ); + assert.doesNotMatch(out("stories.md"), /^Why:|null/m); + const loops = (chart) => [...chart.matchAll(/^ {2}(s\d+) --> \1 : (.*)$/gm)].map((m) => m[2]); + assert.deepEqual(loops(out("bug-1.mmd")), ["1. you look
2. you scroll
3, 4. you look"]); + assert.deepEqual(loops(out("chart.mmd")), ["you look or you scroll"]); + const chart = out("chart.mmd"); + assert.ok(chart.includes('state "Works on main" as legendWorks')); + assert.ok(chart.includes('state "Wrong on main" as legendWrong')); + assert.doesNotMatch(chart, /legendFixed/); + }, +); + +test("a rule that stays broken reports only the step that broke it", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Buzz.tla"), + `---- MODULE Buzz ---- +EXTENDS Naturals +VARIABLES x, y, buzzed +vars == <> +Init == x = 0 /\\ y = 0 /\\ buzzed = FALSE +IncX == x < 6 /\\ x' = x + 1 /\\ UNCHANGED <> +IncY == y < 6 /\\ y' = y + 1 /\\ UNCHANGED <> +Ring == x = 2 /\\ ~buzzed /\\ buzzed' = TRUE /\\ UNCHANGED <> +Next == IncX \\/ IncY \\/ Ring +Spec == Init /\\ [][Next]_vars +NeverBuzzed == ~buzzed +==== +`, + ); + writeFileSync(join(dir, "Buzz.cfg"), "SPECIFICATION Spec\nINVARIANT NeverBuzzed\n"); + writeFileSync( + join(dir, "Buzz.map.mjs"), + 'export const state = (v) => (v.buzzed ? "Buzzed" : "Quiet");\nexport const step = () => "time passes";\n', + ); + const r = node("statechart.mjs", ["from-spec", "Buzz.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const traces = parseTraces( + readFileSync(join(dir, "Buzz-statechart", "tlc", "Buzz-NeverBuzzed.out"), "utf8"), + ); + assert.equal(traces.length, 7); + for (const t of traces) + assert.equal( + t.steps.findIndex((s) => s.vars.buzzed), + t.steps.length - 1, + ); +}); + +test("from-spec warns when steps carry the name of the whole next-state action", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const count = (next) => `---- MODULE Count ---- +EXTENDS Naturals +VARIABLES x +Init == x = 0 +Inc == x < 3 /\\ x' = x + 1 +${next} +Spec == \\* not [][Inc]_x + Init /\\ [][Next]_x +==== +`; + writeFileSync(join(dir, "Count.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Count.map.mjs"), "export const state = (v) => String(v.x);\n"); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc \\/ (x = 3 /\\ x' = 0)")); + const unnamed = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(unnamed.status, 0, unnamed.stderr); + assert.match( + unnamed.stderr, + /^Count\.tla: 1 step has the name Next, the whole next-state action/, + ); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc")); + const named = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(named.status, 0, named.stderr); + assert.equal(named.stderr, ""); +}); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs index d5a3f4c33..a933666d0 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import { + FULL_LABELS, GROUP_PALETTE, LEGEND_ID, PLAIN_FILL, @@ -12,6 +13,7 @@ import { groupClassDef, groupColorProblem, isRecord, + joinLabels, parseDot, parseMermaid, parseTraces, @@ -35,26 +37,29 @@ statechart.mjs runs it once for the overview and once for each bug chart. optionally detail(vars, facts), step(action, before, after), guard(action, before, after), timer(action, before, after), cause(vars, facts, invariant), group(vars, facts), groupColors, - regionNames, and readerActions. guard is when the step + regionNames, readerActions, and legend. guard is when the step can happen, in plain words. An edge reads "step [words]" when every model step behind it has words. timer is how many seconds the code waits before a timer step, and the step reads "after N s, step". cause is the hidden reason a bad state is bad: counterexamples with the - same invariant and cause are one bug. readerActions are the actions the - reader takes. group is the group of a state, below. + same invariant and cause are one bug. When cause returns nothing, the + end state stands in for it. readerActions are the actions the + reader takes. group is the group of a state, below. legend, such as + { works: "Works on main" }, gives the legend's words for the works, + wrong, and fixed statuses. facts.enabled is the set of actions the state enables. facts.settled is true when only reader actions are enabled, so nothing else happens until the reader acts. --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one per bad state. It refuses a file from a TLC run that stopped early or hit an error other than a broken rule. - --fixed-dot FILE The state graph of the model with the open fix. Counterexamples replay on - it. + --fixed-dot FILE The state graph of the model with the open fix. Each bug's steps replay + on it. --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks states fixed or still wrong, and adds the bugs that only the fixed model has after the current model's bugs. --against-dot FILE The state graph of the spec that the current code must follow. - Counterexamples replay on it. Do not use it with --fixed-dot. + Each bug's steps replay on it. Do not use it with --fixed-dot. --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the bugs that only the spec has number after the code's bugs. --label TEXT The name of the second model in the chart and the stories. Default: @@ -67,7 +72,9 @@ statechart.mjs runs it once for the overview and once for each bug chart. counterexample paths. --highlight N Draw bug N as a numbered path. Default 1. 0 draws none. --path-only Draw only the highlighted path. - --all-labels Write every label on an edge. Default: the two most common, then " or …". + --full-labels Write each label on an edge in full, joined by " or ", and mark the chart + "${FULL_LABELS}". Default: write the leading words that the labels + share once, as in "you tap Pay or Back". --flat Draw every state on its own, with no boxes. --regions When every state reads "context: progress", draw two parallel regions, context and progress, when they are smaller and proved. They draw the @@ -83,6 +90,14 @@ statechart.mjs runs it once for the overview and once for each bug chart. --summary FILE The counts for the chart and the bugs, as JSON. -h, --help Print this help, and exit 0. +Each bug replays on the second model by its screens and step words, which are what the reader +sees. The replay takes the second model's steps that keep the screen and that the reader does not +take. A bug step that the reader does not see must keep the screen on the second model too, unless +the second model cannot take it. The replay stops at the first step that the second model shows +the reader differently. There the chart draws "with LABEL, STEP" (or "in LABEL, STEP" for +--against-dot) to where the second model goes, or a loop "... STEP cannot happen" when it cannot +take a step that the reader sees. + The second model may have other variables and actions than the current model. Variables and record fields that one start state has and the other does not are left out when the two models are compared. The second model's states take the current model's start values for the variables @@ -107,8 +122,12 @@ a step that they make wrongly gets back every arrow that the full regions take t compare-charts.mjs on the --regions-all and --proof files repeats the first proof. mapping.regionNames = [context, progress] names the two regions. +Outside regions, two states have at most one arrow. Its lines are the numbered steps, then the +current model's steps, then the second model's steps, with a blank line between each group. + Without group(), the legend shows only the statuses the chart uses. A chart with a second model -has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. +has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. Each chart +starts with front matter that keeps Mermaid from shrinking it to the page or rewrapping its labels. Groups: group(vars, facts) returns the name of the group that a model state is in, such as "Toast shown", or nothing. A chart state is in a group when all its model states are in it. A @@ -160,7 +179,7 @@ for (let i = 2; i < process.argv.length; i++) { else if (a === "--view") args.view = next(); else if (a === "--highlight") args.highlight = Number(next()); else if (a === "--path-only") args.pathOnly = true; - else if (a === "--all-labels") args.allLabels = true; + else if (a === "--full-labels") args.fullLabels = true; else if (a === "--flat") args.flat = true; else if (a === "--regions") args.regions = true; else if (a === "--proof") args.proof = next(); @@ -227,9 +246,9 @@ function keyOf(vars, facts) { throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; } -function stepLabel(action, before, after) { +function stepLabel(action, before, after, view = args.view) { const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; - const named = args.view === "customer" || said === action ? said : `${said} [${action}]`; + const named = view === "customer" || said === action ? said : `${said} [${action}]`; const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null; if (seconds === undefined || seconds === null || seconds === "" || !named) return named; if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) @@ -324,7 +343,7 @@ function quotient(m) { edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); } - // Most common wording first. A chart edge shows two unless --all-labels. + // Most common wording first. for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); const settled = new Set( @@ -358,21 +377,26 @@ if (secondGraph) { // Reader path of one trace: drop system steps that keep the reader in the same state. // Reader actions stay even then, because the reader did them. -function readerPath(trace, key) { +function readerPath(trace, key, labelOf = stepLabel) { const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; for (let i = 1; i < trace.steps.length; i++) { + const { action } = trace.steps[i]; const k = key(trace.steps[i].vars); - if (k === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; + if (k === out[out.length - 1].key && !readerActions.has(action)) continue; const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; - const label = stepLabel(trace.steps[i].action, before, after) || trace.steps[i].action; - const guard = stepGuard(trace.steps[i].action, before, after); - out.push({ key: k, label, guard, modelStep: i }); + const label = labelOf(action, before, after) || action; + const guard = stepGuard(action, before, after); + out.push({ key: k, label, guard, action, modelStep: i }); } return out; } -// Replay a counterexample on the second model, comparing only the variables both models have. -// Returns where the second model leaves the counterexample. +const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer"); +// Replay a bug on the second model, step by step. The reader sees the screens and step words of its +// reader path. The reader does not see a step that keeps the screen and that they do not take, so +// the second model must keep the screen at that step too, or be unable to take it. Returns the +// first step that the second model shows the reader differently, or, when it shows them all the +// same way, whether a state on the way breaks the same rule there. function replaySecond(trace) { if (!second) return null; const shared = (vars) => stateKey(omit(vars, lacks)); @@ -384,41 +408,76 @@ function replaySecond(trace) { } const actions = new Set([...secondGraph.edges, ...secondGraph.loops].map((e) => e.action)); const nodeOf = (id) => secondGraph.nodes.get(id); - let cur = [...secondGraph.nodes.values()].find( - (n) => n.init && sharedOf(n) === shared(trace.steps[0].vars), + const screen = new Map( + [...secondGraph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), + ); + const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action; + const settle = (ids) => { + const all = new Set(ids); + for (const id of all) + for (const e of out.get(id) ?? []) + if (!readerActions.has(e.action) && screen.get(e.to) === screen.get(id)) all.add(e.to); + return all; + }; + const seen = readerPath(trace, main.customer, readerLabel); + const shownAt = new Map(seen.map((p, j) => [p.modelStep, j])); + let here = settle( + [...secondGraph.nodes.values()] + .filter((n) => n.init && screen.get(n.id) === seen[0].key) + .map((n) => n.id), ); - if (!cur) return { step: 0, reason: "starts somewhere else" }; + if (here.size === 0) return { reason: "starts on another screen" }; + const passed = new Set(here); + let j = 0; for (let i = 1; i < trace.steps.length; i++) { - const want = trace.steps[i]; - const target = shared(want.vars); - const from = out.get(cur.id) ?? []; - const exact = from.find((e) => e.action === want.action && sharedOf(nodeOf(e.to)) === target); - if (exact) { - cur = nodeOf(exact.to); + const { action } = trace.steps[i]; + const hidden = !shownAt.has(i); + if (!hidden) j = shownAt.get(i); + const want = seen[j]; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const known = actions.has(action); + if (!known && (hidden || shared(before) === shared(after))) continue; + const tries = [...here].flatMap((id) => + (out.get(id) ?? []).filter((e) => + known ? e.action === action : sharedOf(nodeOf(e.to)) === shared(after), + ), + ); + if (hidden && tries.length === 0) continue; + const same = known + ? tries.filter((e) => screen.get(e.to) === want.key && (hidden || words(e) === want.label)) + : tries; + if (same.length) { + here = settle(same.map((e) => e.to)); + for (const id of here) passed.add(id); continue; } - if (!actions.has(want.action)) { - if (target === sharedOf(cur)) continue; - const any = from.find((e) => sharedOf(nodeOf(e.to)) === target); - if (any) { - cur = nodeOf(any.to); - continue; - } + const outcomes = new Map(); + for (const e of tries) { + const id = `${screen.get(e.to)}${SEP}${words(e)}`; + outcomes.set(id, [...(outcomes.get(id) ?? []), e]); } - const wanted = omit(want.vars, lacks); - const score = (n) => { - const have = omit(n.raw, extra); - return Object.keys(wanted).filter((k) => stateKey(have[k]) === stateKey(wanted[k])).length; - }; - const best = from - .filter((e) => e.action === want.action) - .map((e) => nodeOf(e.to)) - .sort((a, b) => score(b) - score(a))[0]; - const before = trace.steps[i - 1].vars; - const vars = fill((best ?? cur).raw, lacks, before); - return { step: i, action: want.action, disabled: !best, key: second.key(vars), vars, before }; + const taken = [...outcomes.values()].sort((a, b) => b.length - a.length)[0]?.[0]; + const from = main.key(before); + const said = hidden ? readerLabel(action, before, after) || action : want.label; + const at = { step: j, said, from, hidden }; + if (!taken) + return { + ...at, + key: from, + label: stepLabel(action, before, after) || action, + disabled: true, + }; + const [b, a] = [nodeOf(taken.from).vars, nodeOf(taken.to).vars]; + return { ...at, key: second.key(a), label: stepLabel(taken.action, b, a) || taken.action }; } - return { step: trace.steps.length, sameEnd: true }; + const bad = new Set( + rawSecondTraces + .filter((t) => t.invariant === trace.invariant) + .map((t) => stateKey(t.steps[t.steps.length - 1].vars)), + ); + const ruleHolds = + secondTraceFiles.length > 0 && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars))); + return { sameEnd: true, ruleHolds }; } function readTraces(files, paths = []) { @@ -463,10 +522,11 @@ function group(cuts, m) { for (const trace of cuts) { const rp = readerPath(trace, m.customer); const last = trace.steps[trace.steps.length - 1].vars; - const cause = + const said = typeof mapping.cause === "function" - ? String(mapping.cause(last, m.facts(last), trace.invariant)) + ? mapping.cause(last, m.facts(last), trace.invariant) : null; + const cause = said === undefined || said === null ? null : String(said); const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; const prev = byId.get(id); if ( @@ -511,12 +571,14 @@ for (const c of introduced) { classes.push(...introduced); // ---------- status ---------- -// A chart state is bad when it holds a state that breaks a rule, or when every path from the start to -// it passes such a state: what the reader sees after a bug is part of that bug. +// A chart state is bad when it holds a state where a rule breaks, or when every path from the start +// to it passes such a state: what the reader sees after a bug is part of that bug. TLC stops each +// path at the first state that breaks the rule, so a later state that breaks it again does not +// color its chart state when a path without a bug also reaches that chart state. // With a fix, a state is still wrong when it is bad in the fixed model, and fixed when it is bad only // in the current model; without --fixed-traces every bad state is wrong. // Against a spec, a state is wrong when it is bad in either model, and green when only the spec has it. -const diverges = (c) => c.second?.key !== undefined && c.second.key !== c.end; +const diverges = (c) => c.second?.key !== undefined; function badKeys(m, keys, raw) { const g = m.graph; const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); @@ -548,7 +610,6 @@ const badSecond = ? badNow : new Set(); const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); -for (const c of classes) if (diverges(c)) allNodes.add(c.second.key); const onlySecond = (k) => second && !now.nodes.has(k); const status = (k) => { if (role === "against") @@ -560,7 +621,7 @@ const status = (k) => { const edges = new Map(now.edges); if (secondQ) { - const only = (l) => `${l} (${within} ${L})`; + const only = (l) => `${within} ${L}, ${l}`; for (const [id, e] of secondQ.edges) { if (!edges.has(id)) edges.set(id, { @@ -571,33 +632,22 @@ if (secondQ) { }); } } -// The second model changes one model step of the counterexample. Its edge leaves the chart state +// The second model shows the reader one step of the bug differently. Its edge leaves the chart state // before that step and ends where the second model goes instead, or stays put when the second model -// cannot take the step. -const secondEdgeId = (c) => `${c.second.from}${SEP}${SEP}${c.second.key}${SEP}second`; +// cannot take the step. A step that the chart already draws there, in the same words, adds no edge. for (const c of classes) { if (!diverges(c)) continue; - const { action, before } = c.second; - const after = c.trace.steps[c.second.step].vars; - c.second.from = main.key(before); - c.second.said = stepLabel(action, before, after) || action; - c.second.said2 = stepLabel(action, before, c.second.vars) || c.second.said; - const id = secondEdgeId(c); - if (!edges.has(id)) - edges.set(id, { - from: c.second.from, - to: c.second.key, - labels: new Set(), - secondOnly: true, - secondOf: new Set(), - }); - const blocked = role === "fix" ? `blocked by ${L}` : `not in ${L}`; - edges - .get(id) - .labels.add( - c.second.disabled ? `${c.second.said}, ${blocked}` : `${c.second.said2}, ${within} ${L}`, - ); - edges.get(id).secondOf.add(c); + const s = c.second; + const plain = `${s.from}${SEP}${SEP}${s.key}`; + s.text = `${within} ${L}, ${s.label}${s.disabled ? " cannot happen" : ""}`; + if (!s.disabled && [s.label, s.text].some((l) => edges.get(plain)?.labels.has(l))) { + s.edge = plain; + continue; + } + s.edge = `${plain}${SEP}second`; + if (!edges.has(s.edge)) + edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); + edges.get(s.edge).labels.add(s.text); } // ---------- highlighted path ---------- @@ -609,13 +659,10 @@ const pathNumbers = new Map(); if (chosen) { chosen.path.slice(1).forEach((p, i) => { const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; - if (!pathNumbers.has(id)) - pathNumbers.set(id, { - n: [], - label: p.label, - shown: p.guard ? `${p.label} [${p.guard}]` : p.label, - }); - pathNumbers.get(id).n.push(i + 1); + if (!pathNumbers.has(id)) pathNumbers.set(id, []); + pathNumbers + .get(id) + .push({ n: i + 1, label: p.label, shown: p.guard ? `${p.label} [${p.guard}]` : p.label }); if (!edges.has(id)) edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); }); @@ -652,18 +699,19 @@ if (args.cover) { secondQ, [...secondQ.settled].filter((k) => !now.nodes.has(k)), ); + const pathLabels = new Map(); for (const c of classes) { - c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); - if (diverges(c)) keepEdges.add(secondEdgeId(c)); + c.path.slice(1).forEach((p, i) => { + const id = `${c.path[i].key}${SEP}${SEP}${p.key}`; + keepEdges.add(id); + pathLabels.set(id, (pathLabels.get(id) ?? new Set()).add(p.label)); + }); + if (diverges(c)) keepEdges.add(c.second.edge); } for (const id of keepEdges) { if (!edges.has(id)) { const [from, to] = id.split(`${SEP}${SEP}`); - const c = classes.find((x) => - x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to), - ); - const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); - edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); + edges.set(id, { from, to, labels: pathLabels.get(id) ?? new Set() }); } } coverEdges = keepEdges; @@ -671,9 +719,15 @@ if (args.cover) { // ---------- Mermaid ---------- const LEGEND = { - works: ["legendWorks", role === "fix" ? "Works today" : "Works"], - wrong: ["legendWrong", role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"], - fixed: ["legendFixed", role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`], + works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")], + wrong: [ + "legendWrong", + mapping.legend?.wrong ?? (role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"), + ], + fixed: [ + "legendFixed", + mapping.legend?.fixed ?? (role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`), + ], }; // ---------- groups ---------- @@ -696,7 +750,6 @@ if (grouping) { for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); if (second) for (const n of secondGraph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); - for (const c of classes) if (diverges(c)) add(c.second.key, c.second.vars, second); const given = mapping.groupColors ?? {}; if (!isRecord(given)) @@ -754,12 +807,22 @@ const groupCounts = (drawn) => })) .filter((g) => g.states > 0); +// Mermaid shrinks a chart to the page width unless useMaxWidth is false, and rewraps labels that +// wrap() already wrapped unless markdownAutoWrap is false. +const frontMatter = (title) => [ + "---", + ...(title ? [`title: ${JSON.stringify(title)}`] : []), + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", +]; // drawn holds { group, status, cls } for each state the chart draws. function head(drawn) { - const out = []; - if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); - out.push("stateDiagram-v2"); + const out = [...frontMatter(args.title), "stateDiagram-v2"]; if (role) out.push(` %% second model (${role}): ${L}`); + if (args.fullLabels) out.push(` ${FULL_LABELS}`); const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s)); const legend = grouping ? [ @@ -821,6 +884,7 @@ function regionsChart() { }; const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`])); const proof = [ + ...frontMatter(), "stateDiagram-v2", ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), @@ -991,7 +1055,7 @@ function regionsChart() { const e = edges.get(`${c.path[j].key}${SEP}${SEP}${p.key}`); if (e) stepsOf(e, norm(p.label)).forEach(want); }); - if (diverges(c)) stepsOf(edges.get(secondEdgeId(c)), "").forEach(want); + if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want); } const cheapest = () => { const cost = new Map(starts.map((k) => [bar(split(k)), 0])); @@ -1122,26 +1186,61 @@ for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); const caption = (k) => args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; -let edgeCount = 0; +// Consecutive steps of one arrow with the same words share a line, as in "2, 3. you look". +function numbered(steps) { + const runs = []; + for (const { n, shown } of steps) { + const last = runs.at(-1); + if (last?.shown === shown && last.n.at(-1) === n - 1) last.n.push(n); + else runs.push({ shown, n: [n] }); + } + return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`); +} const drawnLabels = new Set(); -const lines = []; +// Mermaid stacks the loops on a state in one place, so two states get one arrow. A blank line sets +// its numbered steps, the current model's steps, and the second model's steps apart, since a +// wrapped line looks like a new one. The blank line holds a no-break space, because Mermaid drops +// an empty line. +const arrows = new Map(); for (const [id, e] of edges) { if (!keep.has(e.from) || !keep.has(e.to)) continue; - if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.secondOf?.has(chosen)) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue; if (coverEdges && !coverEdges.has(id)) continue; + const pair = `${e.from}${SEP}${SEP}${e.to}`; + if (!arrows.has(pair)) + arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] }); + const a = arrows.get(pair); + const [numbers, current, other] = a.groups; const all = [...e.labels]; const shown = all.map((l) => guarded(e.guards, l)); - let text = args.allLabels - ? shown.join(" or ") - : shown.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + const said = args.fullLabels ? shown.join(" or ") : joinLabels(shown); const p = pathNumbers.get(id); - if (p) text = `${p.n.join(", ")}. ${p.shown}`; - for (const l of p ? [p.label] : all) drawnLabels.add(l); - // ";" ends a Mermaid statement, even inside a transition label. - text = text.replaceAll(";", ","); - lines.push({ from: e.from, to: e.to, text, plain: !p && !e.secondOf, labels: [...all].sort() }); - edgeCount++; + const chosenStep = chosen?.second && id === chosen.second.edge; + if (p) numbers.push(...numbered(p)); + else if (!e.secondOnly) current.push(said); + else other.push(args.pathOnly && chosenStep ? chosen.second.text : said); + if (chosenStep && !e.secondOnly) other.push(chosen.second.text); + if (p || chosenStep || id.endsWith(`${SEP}second`)) a.plain = false; + a.labels.push(...all); + for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l); } +const lines = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ + from, + to, + // ";" ends a Mermaid statement, even inside a transition label. + text: groups + .map((g) => + g + .filter(Boolean) + .map((l) => wrap(l.replaceAll(";", ","), 30)) + .join("
"), + ) + .filter(Boolean) + .join("
#160;
"), + plain, + labels: labels.sort(), +})); +const edgeCount = lines.length; // One arrow from a box's border stands for the same step from every state in the box. It is drawn // only when every state in the box has that step, with the same labels, to the same state outside // the box, so the chart still says exactly what each state does. @@ -1174,7 +1273,7 @@ const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); -const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${wrap(l.text, 30)}` : ""}`; +const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`; for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); for (const l of lines) out.push(arrow(idOf(l.from), l)); sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); @@ -1185,27 +1284,26 @@ else process.stdout.write(mermaid); // ---------- stories and summary ---------- const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; -// The reader step of a model step, or the last reader step before it when the reader saw no change. -function readerStep(c, step) { - const at = c.path.findIndex((p) => p.modelStep === step); - if (at >= 0) return { at }; - return { after: c.path.findLastIndex((p) => p.modelStep < step) }; -} function secondLine(c) { const W = cap(within); if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`; const s = c.second; if (!s) return null; - if (s.sameEnd) return `${W} ${L}, the same steps end in the same place.`; + if (s.sameEnd) + return s.ruleHolds + ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.` + : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`; if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; - const { at, after } = readerStep(c, s.step); - if (s.disabled) - return at !== undefined - ? `${W} ${L}, step ${at} cannot happen, so you stay at: ${show(s.key)}.` - : `${W} ${L}, this path ends after step ${after}, and you stay at: ${show(s.key)}.`; - return at !== undefined - ? `${W} ${L}, step ${at} is "${s.said2}", and you see: ${show(s.key)}.` - : `${W} ${L}, after step ${after}, "${s.said2}" happens, and you see: ${show(s.key)}.`; + const outcome = s.disabled + ? "cannot happen, so you stay at" + : s.key === s.from + ? "keeps you at" + : "takes you to"; + if (s.hidden) { + const when = s.step ? `after step ${s.step}` : "before your first step"; + return `${W} ${L}, ${when}, "${s.said}" happens and ${outcome}: ${show(s.key)}.`; + } + return `${W} ${L}, step ${s.step} (${s.said}) ${outcome}: ${show(s.key)}.`; } if (args.story) { const lines = []; @@ -1246,7 +1344,9 @@ const secondOf = (c) => : `only in ${L}` : c.second ? c.second.sameEnd - ? "unchanged" + ? c.second.ruleHolds + ? "same screens, but the rule holds" + : "unchanged" : (c.second.reason ?? show(c.second.key)) : null; const summary = { diff --git a/pstack/skills/statechart/scripts/compare-charts.mjs b/pstack/skills/statechart/scripts/compare-charts.mjs index d3de630e7..3ddb3311a 100644 --- a/pstack/skills/statechart/scripts/compare-charts.mjs +++ b/pstack/skills/statechart/scripts/compare-charts.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import fs from "node:fs"; import process from "node:process"; -import { LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; +import { FULL_LABELS, LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"; const HELP = `${USAGE} @@ -11,7 +11,7 @@ transitions. statechart.mjs from-chart runs it to check that the spec still draw the reader agreed to. EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer -view with --highlight 0 --all-labels --flat, without --cover, --fixed-dot, or --against-dot. Each +view with --highlight 0 --full-labels --flat, without --cover, --fixed-dot, or --against-dot. Each chart compares as the machine that its steps reach from its start states. A chart with parallel regions compares as its combined machine, so a guard that blocks a step in only some combinations of regions shows as a @@ -59,18 +59,24 @@ const orExit = (file, f) => { } }; const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8"))); +const has = (re) => re.test(actualText); const textRefusals = [ [ - /^\s*%% second model/m, + has(/^\s*%% second model/m), "it has a second model; draw ACTUAL without --fixed-dot or --against-dot", ], [ - new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m"), + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m")), "it has boxes; draw ACTUAL with --flat", ], + [ + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+${LEGEND_ID}\\s*\\{`, "m")) && + !actualText.includes(FULL_LABELS), + "edge labels write shared words once; draw ACTUAL with --full-labels", + ], ]; for (const [bad, why] of textRefusals) { - if (bad.test(actualText)) { + if (bad) { console.error(`${actualFile}: ${why}`); process.exit(2); } @@ -80,7 +86,6 @@ const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions) const drawn = Object.values(actualRegions).flatMap((r) => r.transitions); const refusals = [ - [(l) => / or …$/.test(l), 'an edge label ends in " or …"; draw ACTUAL with --all-labels'], [ (l) => /^\d+(, \d+)*\. /.test(l), "an edge label is a numbered path step; draw ACTUAL with --highlight 0", diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index 8fbac3be2..090c1a8d1 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -290,9 +290,27 @@ export function wrap(text, width = 26) { return rows.join("
"); } +// The shared words stop before a word that opens a phrase, so each choice keeps its whole phrase, +// as in "you tap the Pay button or the Back button". +const OPENS_A_PHRASE = new Set( + "a an the your my our their its this that to of in on at for with from by and or".split(" "), +); +export function joinLabels(labels) { + if (labels.length < 2) return labels.join(" or "); + const words = labels.map((l) => String(l).split(" ")); + let n = 0; + while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++; + while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--; + if (n < 2) return labels.join(" or "); + const rest = words.map((w) => w.slice(n).join(" ")); + const list = + rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`; + return `${words[0].slice(0, n).join(" ")} ${list}`; +} + const unwrap = (s) => s - .replace(//g, " ") + .replace(/|#160;/g, " ") .replace(/#(quot|34);/g, '"') .replace(/\s+/g, " ") .trim(); @@ -302,6 +320,8 @@ export const quote = (s) => String(s).replaceAll('"', "#quot;"); // The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; +// Marks a chart whose edge labels are written in full, which a chart comparison needs. +export const FULL_LABELS = "%% labels in full"; // Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label,guard?}] } }, nevers }. // Composite states flatten to "Parent.Child". A transition on a composite leaves from each state @@ -336,7 +356,8 @@ export function parseMermaid(text) { part().children.push({ name: id, leaf: true }); implicit.set(id, part()); }; - for (const raw of text.split("\n")) { + const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, ""); + for (const raw of body.split("\n")) { const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); if (when) { @@ -362,7 +383,7 @@ export function parseMermaid(text) { if ( !line || line.startsWith("%%") || - /^(stateDiagram|direction|classDef|class |note|title|---)/.test(line) + /^(stateDiagram|direction|classDef|class |note)/.test(line) ) continue; let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); diff --git a/pstack/skills/statechart/scripts/statechart.mjs b/pstack/skills/statechart/scripts/statechart.mjs index 86ff7154a..ada61542f 100644 --- a/pstack/skills/statechart/scripts/statechart.mjs +++ b/pstack/skills/statechart/scripts/statechart.mjs @@ -19,9 +19,13 @@ const usage = (lines) => `Usage: ${lines.join("\n ")}`; const FROM_SPEC = `from-spec SPEC.tla Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state - breaks, and the separate runs find the rest. SPEC.cfg sits next to the spec and has SPECIFICATION - and one INVARIANT line per rule. One more run dumps the state graph, and then the command draws - the charts. + breaks, and the separate runs find the rest. Each run also has the rule as a CONSTRAINT, so TLC + stops each path at its first state that breaks the rule, and a rule that stays broken reports + only the step that broke it. SPEC.cfg sits next to the spec and has SPECIFICATION and one + INVARIANT line per rule. One more run dumps the state graph, and then the command draws the + charts. TLC names each step after the innermost named action that takes it, so give each kind of + step its own named action. The command warns when steps carry the name of the whole next-state + action. --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks @@ -106,14 +110,19 @@ Reading a chart When the mapping exports group(), the fill is the state's group, which the legend names, and the status is a thick red or green border on top. tlc-to-chart.mjs --help tells more. A state is bad when it breaks a rule, or when every path from the start to it passes a state - that breaks one. + that breaks one. TLC stops each path at the first state that breaks the rule, so a rule that + stays broken colors only the states where it first breaks and the states after them that no + path without a bug reaches. A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an edge. - An edge labeled "with the fix" or "in the spec" leaves the state before the step that the other - model changes, and ends where that model goes instead. "blocked by the fix" or "not in the spec" - loops on the state where that model stops the step. "(with the fix)" or "(in the spec)" marks an - edge that only the other model has. - An edge shows its two most common labels. "or …" means that there are more. + A label "with the fix, STEP" or "in the spec, STEP" is a step that only the other model takes. On + a bug's path it leaves the state before the first step that the other model shows the reader + differently, and ends where that model goes instead. "with the fix, STEP cannot happen" or "in + the spec, STEP cannot happen" loops on the state where that model stops the step. Outside + regions, two states have at most one arrow between them. Its lines are the numbered steps, then + SPEC.tla's steps, then the other model's steps, with a blank line between each group. + An edge lists every step that takes it, most common first. Words that the steps share at the + start are written once, as in "you tap Pay or Back". Regenerate With --expect-broken, one script rebuilds every chart and stops on any change: @@ -150,69 +159,64 @@ ${COMMON}`, "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`, }; -function fail(message) { - console.error(message); - process.exit(2); -} - -const [command, ...rest] = process.argv.slice(2); const more = (cmd) => `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; -if (command === "-h" || command === "--help") { - console.log(HELP[""]); - process.exit(0); -} -const FLAGS = { - "from-spec": ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], - "from-chart": ["--module", "--expect-broken", "--title", "--out"], + +class CannotFinish extends Error {} + +const COMMANDS = { + "from-spec": { + flags: ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + run: fromSpec, + }, + "from-chart": { flags: ["--module", "--expect-broken", "--title", "--out"], run: fromChart }, }; -if (!FLAGS[command]) fail(`${usage(Object.values(USAGE))}\n${more()}`); -const args = { "expect-broken": [] }; -let input; -for (let i = 0; i < rest.length; i++) { - const a = rest[i]; - if (a === "-h" || a === "--help") { - console.log(HELP[command]); - process.exit(0); - } else if (a === "--png") args.png = true; - else if (FLAGS[command].includes(a)) { - if (i + 1 === rest.length) fail(`${a} needs a value. ${more(command)}`); - const name = a.slice(2); - if (Array.isArray(args[name])) args[name].push(rest[++i]); - else args[name] = rest[++i]; - } else if (!a.startsWith("-") && input === undefined) input = a; - else fail(`unknown argument ${a}. ${more(command)}`); + +function parseArgs([command, ...rest]) { + if (command === "-h" || command === "--help") return { help: HELP[""] }; + if (!Object.hasOwn(COMMANDS, command)) + throw new CannotFinish(`${usage(Object.values(USAGE))}\n${more()}`); + const flags = { "expect-broken": new Set() }; + let input; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "-h" || a === "--help") return { help: HELP[command] }; + if (a === "--png") flags.png = true; + else if (COMMANDS[command].flags.includes(a)) { + if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`); + const name = a.slice(2); + if (flags[name] instanceof Set) flags[name].add(rest[++i]); + else flags[name] = rest[++i]; + } else if (!a.startsWith("-") && input === undefined) input = a; + else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`); + } + if (input === undefined) throw new CannotFinish(`${usage([USAGE[command]])}\n${more(command)}`); + return { command, input, flags }; } -if (input === undefined) fail(`${usage([USAGE[command]])}\n${more(command)}`); -if (args.fixed && args.against) fail(`use --fixed or --against, not both. ${more(command)}`); -if (args.label !== undefined && !args.fixed && !args.against) - fail(`--label names the --fixed or --against model; it needs one of them. ${more(command)}`); -const expected = new Set(args["expect-broken"]); const rel = (file) => path.relative(process.cwd(), file) || "."; -let tools; -function needTlc() { +const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg"); + +function findTools() { try { - tools = findTlc(); + return findTlc(); } catch (e) { - fail(e.message); + throw new CannotFinish(e.message); } } -// The child scripts throw on input they cannot use. Print the message and the file it is about, not the stack. -function script(name, scriptArgs, about) { +function script(name, scriptArgs, about, { okExits = [0] } = {}) { const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { encoding: "utf8", }); - if (r.status === 0 || (name === "compare-charts.mjs" && r.status === 1)) return r; + if (okExits.includes(r.status)) return r; const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; - fail(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); + throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); } const KEYWORD = /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/; -// The config without its INVARIANT sections, and the rules they name. function splitRules(cfg) { const words = cfg .replace(/\(\*[^]*?\*\)/g, " ") @@ -227,10 +231,10 @@ function splitRules(cfg) { if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); else if (w !== section && !rules.includes(w)) rules.push(w); } - return { base: `${kept.join(" ")}\n`, rules }; + return { cfgWithoutRules: `${kept.join(" ")}\n`, rules }; } -function tlc(spec, cfg, base, dump) { +function tlc(tools, spec, cfg, base, dump) { fs.writeFileSync(`${base}.cfg`, cfg); const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. @@ -252,36 +256,67 @@ function tlc(spec, cfg, base, dump) { { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 }, ); fs.rmSync(metadir, { recursive: true, force: true }); - if (r.error) fail(`cannot run TLC: ${r.error.message}`); + if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); fs.writeFileSync(`${base}.out`, r.stdout); try { checkTlcOutput(r.stdout, rel(`${base}.out`)); } catch (e) { - fail(e.message); + throw new CannotFinish(e.message); } return r.stdout; } -// main is true for the input spec: --expect-broken names its rules. -function check(spec, cfgFile, tlcDir, main) { - const name = path.basename(spec, ".tla"); - if (!fs.existsSync(spec)) fail(`${rel(spec)} does not exist`); +function warnUnnamedSteps(spec, cfg, dot) { + const text = fs + .readFileSync(spec, "utf8") + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " "); + const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1]; + const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text); + const next = + /^NEXT\s+(\w+)/m.exec(cfg)?.[1] ?? /\[\]\s*\[\s*(\w+)\s*\]_/.exec(specBody?.[1] ?? text)?.[1]; + const graph = parseDot(fs.readFileSync(dot, "utf8")); + const unnamed = [...graph.edges, ...graph.loops].filter((e) => e.action === next).length; + if (unnamed > 0) + console.error( + `${rel(spec)}: ${unnamed} ${unnamed === 1 ? "step has" : "steps have"} the name ${next}, the whole next-state action, because TLC names a step after the innermost named action that takes it. If the reader must tell these steps apart, give each part of ${next} its own name, as in Pay == ... and ${next} == Pay \\/ Charge.`, + ); +} + +function readModel(spec, cfgFile) { + if (!fs.existsSync(spec)) throw new CannotFinish(`${rel(spec)} does not exist`); if (!fs.existsSync(cfgFile)) - fail(`${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`); - const { base, rules } = splitRules(fs.readFileSync(cfgFile, "utf8")); - const unknown = main ? [...expected].filter((r) => !rules.includes(r)) : []; + throw new CannotFinish( + `${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`, + ); + return { spec, cfgFile, ...splitRules(fs.readFileSync(cfgFile, "utf8")) }; +} + +function readInput(spec, expected) { + const model = readModel(spec, cfgOf(spec)); + const unknown = [...expected].filter((r) => !model.rules.includes(r)); if (unknown.length) - fail( - `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(cfgFile)}, whose rules are ${rules.join(", ") || "none"}.`, + throw new CannotFinish( + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${model.rules.join(", ") || "none"}.`, ); + return model; +} + +function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, out, expected) { + const name = path.basename(spec, ".tla"); + const tlcDir = path.join(out, "tlc"); const dot = path.join(tlcDir, `${name}.dot`); - tlc(spec, base, path.join(tlcDir, name), dot); + tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); + warnUnnamedSteps(spec, cfgWithoutRules, dot); console.log(`TLC on ${path.basename(spec)}:`); if (rules.length === 0) console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); + // With -continue TLC reports every bad state. The CONSTRAINT stops each path at its first bad + // state, so a rule that stays broken reports only the step that broke it, not every later state. const traces = rules.map((rule) => { const file = path.join(tlcDir, `${name}-${rule}`); - const broken = /^Error: Invariant /m.test(tlc(spec, `${base}INVARIANT ${rule}\n`, file)); + const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`; + const broken = /^Error: Invariant /m.test(tlc(tools, spec, cfg, file)); console.log( ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, ); @@ -290,9 +325,7 @@ function check(spec, cfgFile, tlcDir, main) { return { spec, dot, traces, broken: traces.some((t) => t.broken) }; } -// Without --expect-broken, every rule must hold. With it, exactly those rules break in the input -// spec, and the other model breaks no other rule. -function rulesAsExpected(now, other) { +function rulesAsExpected(now, other, expected) { const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); if (expected.size === 0) return !now.broken && !other?.broken; const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( @@ -357,31 +390,37 @@ export function step(action, before, after) { // Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and // the app drops an upload it cannot send". No variable, function, or event names. // Counterexamples that break one rule with one cause are one bug, so two bugs that end on the -// same screen stay apart only if their causes differ. +// same screen stay apart only if their causes differ. Return null to group by the end state. // export function cause(v, facts, invariant) {} // The developer's words for one model state. With it, each bug is also drawn through these states. // export function detail(v, facts) {} + +// The legend's words for each status, in place of the defaults. Group entries keep their names. +// export const legend = { works: "Works on main", wrong: "Wrong on main", fixed: "Fixed in the open PR" }; `; }; -async function draw({ now, second, role, map, out, comparing }) { +async function draw({ now, other, map, out, title, comparing }) { const mapping = await import(pathToFileURL(path.resolve(map)).href); const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; from.push(...now.traces.flatMap((t) => ["--traces", t.file])); - if (second) { - from.push(`--${role}-dot`, second.dot, "--spec", second.spec); - from.push(...second.traces.flatMap((t) => [`--${role}-traces`, t.file])); - if (args.label !== undefined) from.push("--label", args.label); + if (other) { + from.push(`--${other.role}-dot`, other.dot, "--spec", other.spec); + from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file])); + if (other.label !== undefined) from.push("--label", other.label); } - // compare-charts.mjs needs every edge, every label, and no boxes, and --cover drops edges. - if (comparing) from.push("--all-labels", "--flat"); + if (comparing) from.push("--full-labels", "--flat"); else if (mapping.readerActions?.length) from.push("--cover"); - const titled = (suffix) => (args.title ? ["--title", `${args.title}${suffix}`] : []); + const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []); for (const f of fs.readdirSync(out)) if (/^bug-\d+(-developer|-layered)?\.(mmd|png)$/.test(f)) fs.rmSync(path.join(out, f)); - const files = ["chart.mmd", "stories.md", "summary.json"].map((f) => path.join(out, f)); + const files = { + overview: path.join(out, "chart.mmd"), + stories: path.join(out, "stories.md"), + summary: path.join(out, "summary.json"), + }; const proof = path.join(out, "tlc", "regions-proof.mmd"); const everyArrow = path.join(out, "tlc", "regions-all.mmd"); for (const f of [proof, everyArrow]) fs.rmSync(f, { force: true }); @@ -394,16 +433,16 @@ async function draw({ now, second, role, map, out, comparing }) { "--highlight", "0", "--out", - files[0], + files.overview, "--story", - files[1], + files.stories, "--summary", - files[2], + files.summary, ], map, ); - const summary = JSON.parse(fs.readFileSync(files[2], "utf8")); - const charts = [files[0]]; + const summary = JSON.parse(fs.readFileSync(files.summary, "utf8")); + const charts = [files.overview]; for (const c of summary.classes) { const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; for (const view of views) { @@ -431,9 +470,9 @@ async function draw({ now, second, role, map, out, comparing }) { else if (r) console.log(`It has no parallel regions, because ${r.why}.`); const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; console.log( - role === "fixed" + other?.role === "fixed" ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n` - : role === "against" + : other?.role === "against" ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n` : `${works} work, ${breaks}.\n`, ); @@ -442,22 +481,23 @@ async function draw({ now, second, role, map, out, comparing }) { `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`, ); console.log( - summary.classes.length ? fs.readFileSync(files[1], "utf8").trimEnd() : "No rule is broken.", + summary.classes.length + ? fs.readFileSync(files.stories, "utf8").trimEnd() + : "No rule is broken.", ); return { files, charts }; } function render(charts) { - // Mermaid shrinks a wide chart to the 800px page; keep its natural size so labels stay readable. - const config = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")), "mermaid.json"); - fs.writeFileSync(config, JSON.stringify({ state: { useMaxWidth: false } })); const pngs = []; for (const chart of charts) { const png = chart.replace(/\.mmd$/, ".png"); const r = spawnSync( "npx", - ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2", "-c", config], - { encoding: "utf8" }, + ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2"], + { + encoding: "utf8", + }, ); if (r.status === 0) pngs.push(png); else @@ -468,55 +508,70 @@ function render(charts) { return pngs; } -function finish(out, charts, files, ok) { - const pngs = args.png ? render(charts) : []; - const written = [...charts, ...pngs, ...files.slice(1)].map((f) => path.basename(f)).join(", "); +function finish(out, { files, charts }, ok, png) { + const pngs = png ? render(charts) : []; + const written = [...charts, ...pngs, files.stories, files.summary] + .map((f) => path.basename(f)) + .join(", "); console.log(`\nWrote ${written} in ${rel(out)}.`); - process.exit(args.png && pngs.length < charts.length ? 2 : ok ? 0 : 1); + if (png && pngs.length < charts.length) return 2; + return ok ? 0 : 1; } -const outDir = (name) => { - const out = args.out ?? path.join(path.dirname(input), `${name}-statechart`); +function outDir(flagged, input, name) { + const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`); fs.mkdirSync(path.join(out, "tlc"), { recursive: true }); return out; -}; +} -if (command === "from-spec") { +function secondModel(input, { fixed, against, label }) { + if (fixed && against) + throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); + if (!fixed && !against) { + if (label === undefined) return null; + throw new CannotFinish( + `--label names the --fixed or --against model; it needs one of them. ${more("from-spec")}`, + ); + } + const [role, spec] = fixed ? ["fixed", fixed] : ["against", against]; const name = path.basename(input, ".tla"); - const role = args.fixed ? "fixed" : args.against ? "against" : null; - const other = args.fixed ?? args.against; // TLC's output files are named after the spec, so a second model with the same name overwrites them. - if (other && path.basename(other, ".tla") === name) - fail( - `${rel(other)} needs its own file and module name, such as ${name}${role === "fixed" ? "Fixed" : "Agreed"}.tla.`, + if (path.basename(spec, ".tla") === name) + throw new CannotFinish( + `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, ); - needTlc(); - const out = outDir(name); - const map = args.map ?? path.join(path.dirname(input), `${name}.map.mjs`); - const now = check(input, input.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); + return { role, spec, label }; +} + +async function fromSpec(input, { "expect-broken": expected, ...flags }) { + const second = secondModel(input, flags); + const tools = findTools(); + const name = path.basename(input, ".tla"); + const out = outDir(flags.out, input, name); + const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const now = check(tools, readInput(input, expected), out, expected); if (!fs.existsSync(map)) { fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); - fail(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); + throw new CannotFinish(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); } - let second = null; - if (other) { - const cfg = other.replace(/\.tla$/, ".cfg"); - second = check( - other, - fs.existsSync(cfg) ? cfg : input.replace(/\.tla$/, ".cfg"), - path.join(out, "tlc"), - ); + let other = null; + if (second) { + const { role, spec, label } = second; + const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); + other = { role, label, ...check(tools, readModel(spec, cfg), out, expected) }; } - const { files, charts } = await draw({ now, second, role, map, out }); - finish(out, charts, files, rulesAsExpected(now, second)); -} else { + const drawn = await draw({ now, other, map, out, title: flags.title }); + return finish(out, drawn, rulesAsExpected(now, other, expected), flags.png); +} + +async function fromChart(input, { "expect-broken": expected, ...flags }) { const name = - args.module ?? + flags.module ?? path .basename(input, path.extname(input)) .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase()); if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) - fail(`cannot make a TLA+ module name from ${input}; add --module NAME`); + throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`); const spec = path.join(path.dirname(input), `${name}.tla`); if (fs.existsSync(spec)) { console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); @@ -524,20 +579,41 @@ if (command === "from-spec") { script("chart-to-tla.mjs", [input, "--module", name], input); console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); } - needTlc(); - const out = outDir(name); - const now = check(spec, spec.replace(/\.tla$/, ".cfg"), path.join(out, "tlc"), true); - const { files, charts } = await draw({ + const tools = findTools(); + const out = outDir(flags.out, input, name); + const now = check(tools, readInput(spec, expected), out, expected); + const drawn = await draw({ now, map: spec.replace(/\.tla$/, ".map.mjs"), out, + title: flags.title, comparing: true, }); - const compared = script("compare-charts.mjs", [input, files[0]]); + const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { + okExits: [0, 1], + }); if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); else console.log( `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, ); - finish(out, charts, files, rulesAsExpected(now) && compared.status === 0); + return finish( + out, + drawn, + rulesAsExpected(now, null, expected) && compared.status === 0, + flags.png, + ); +} + +try { + const { help, command, input, flags } = parseArgs(process.argv.slice(2)); + if (help) { + console.log(help); + process.exit(0); + } + process.exit(await COMMANDS[command].run(input, flags)); +} catch (e) { + if (!(e instanceof CannotFinish)) throw e; + console.error(e.message); + process.exit(2); } diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 362968a47..79618ba78 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -1,5 +1,3 @@ -// node --test scripts/test/statechart.test.mjs -// The TLC tests run when statechart.mjs can find TLC. Its --help says where it looks. import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; @@ -27,6 +25,7 @@ const node = (script, args, cwd, env = {}) => cwd, encoding: "utf8", env: { ...process.env, ...env }, + timeout: 120_000, }); test("a transition on a composite leaves from every state inside it", () => { @@ -592,7 +591,6 @@ test("compare refuses a chart it cannot read, or an ACTUAL chart that is not dra assert.match(r.stderr, /^missing\.mmd: ENOENT/); } for (const [extra, why] of [ - [" U_O --> L_O : tap lock or …", /--all-labels/], [" U_O --> L_O : 1. tap lock", /--highlight 0/], [ " %% second model (against): the spec", @@ -611,6 +609,96 @@ test("compare refuses a chart it cannot read, or an ACTUAL chart that is not dra } }); +test("an edge lists every step, writes shared leading words once, and the chart renders at its natural width", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const nodes = [ + ["Home", 1], + ["Home", 2], + ["Home", 3], + ["Away", 1], + ["Away", 2], + ["Done", 1], + ].map( + ([s, n], i) => + `${i + 1} [label="/\\\\ s = \\"${s}\\"\\n/\\\\ n = ${n}"${i ? "" : ",style = filled"}]`, + ); + const steps = [ + "1 -> 4 TapPay", + "2 -> 5 TapBack", + "3 -> 4 TapHome", + "4 -> 1 OpenThread", + "5 -> 2 OpenApp", + "4 -> 6 Finish", + ]; + writeFileSync( + join(dir, "nav.dot"), + `strict digraph DiskGraph {\n${nodes.join("\n")}\n${steps.map((e) => e.replace(/ (\w+)$/, ' [label="$1"];')).join("\n")}\n}\n`, + ); + writeFileSync( + join(dir, "nav.map.mjs"), + `export const state = (v) => v.s; +export const step = (a) => ({ TapPay: "you tap Pay", TapBack: "you tap Back", TapHome: "you tap Home", OpenThread: "you open the thread", OpenApp: "you open the phone app", Finish: "" })[a]; +`, + ); + writeFileSync( + join(dir, "agreed.mmd"), + `stateDiagram-v2 + [*] --> Home + Home --> Away : you tap Pay + Home --> Away : you tap Back + Home --> Away : you tap Home + Away --> Home : you open the thread + Away --> Home : you open the phone app + Away --> Done +`, + ); + const draw = (...extra) => { + const r = node( + "tlc-to-chart.mjs", + [ + "--dot", + "nav.dot", + "--map", + "nav.map.mjs", + "--highlight", + "0", + "--out", + "chart.mmd", + ...extra, + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, "chart.mmd"), "utf8"); + }; + const chart = draw(); + assert.ok( + chart.startsWith( + "---\nconfig:\n markdownAutoWrap: false\n state:\n useMaxWidth: false\n---\nstateDiagram-v2\n", + ), + chart, + ); + const shortened = [ + "Away -> Done : ", + "Away -> Home : you open the thread or the phone app", + "Home -> Away : you tap Pay, Back, or Home", + ]; + assert.deepEqual(edges(parseMermaid(chart).regions.Main), shortened); + const crlf = chart.replace("---\n", "--- \n").replaceAll("\n", "\r\n"); + assert.deepEqual(edges(parseMermaid(crlf).regions.Main), shortened); + const refused = node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir); + assert.equal(refused.status, 2); + assert.match(refused.stderr, /draw ACTUAL with --full-labels/); + const full = draw("--full-labels"); + assert.match(full, /^stateDiagram-v2\n {2}%% labels in full\n/m); + assert.deepEqual(edges(parseMermaid(full).regions.Main), [ + "Away -> Done : ", + "Away -> Home : you open the thread or you open the phone app", + "Home -> Away : you tap Pay or you tap Back or you tap Home", + ]); + assert.equal(node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir).stdout, "same\n"); +}); + const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: https://github.com/tlaplus/tlaplus/releases Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). @@ -795,7 +883,6 @@ const tools = (() => { })(); const needsTlc = { skip: tools ? false : "cannot find TLC" }; -// Check every INVARIANT in NAME.cfg in one run and dump NAME.dot. function tlc(dir, name) { const r = spawnSync( tools.java, @@ -909,7 +996,7 @@ Chart: 4 states and 9 transitions from 4 model states. 3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. ${syncStory} -With the fix, step 2 cannot happen, so you stay at: Offline | Waiting to sync. +With the fix, step 2 (the server confirms) cannot happen, so you stay at: Offline | Waiting to sync. Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. `, @@ -983,36 +1070,56 @@ Wrote chart.mmd, stories.md, summary.json in Sync-statechart. }); test( - "--png exits 2 when a chart does not render, even with --expect-broken, and keeps the other files", + "--png exits 2 unless every chart renders, even with --expect-broken, and keeps the files it wrote", needsTlc, () => { - const dir = mkdtempSync(join(tmpdir(), "statechart-")); - writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); - const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline"]; - assert.equal(node("statechart.mjs", args, dir).status, 0); - // An npx whose mermaid-cli cannot launch a headless browser. - const bin = mkdtempSync(join(tmpdir(), "no-browser-")); + const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline", "--png"]; + const bin = mkdtempSync(join(tmpdir(), "fake-npx-")); const launch = "Error: Failed to launch the browser process!"; - writeFileSync(join(bin, "npx"), `#!/bin/sh\necho "${launch}" >&2\nexit 1\n`, { mode: 0o755 }); - const r = node("statechart.mjs", [...args, "--png"], dir, { - PATH: `${bin}${delimiter}${process.env.PATH}`, - }); - assert.equal(r.status, 2, r.stdout + r.stderr); - assert.equal( - r.stderr, - `could not render Sync-statechart/chart.mmd: ${launch}\ncould not render Sync-statechart/bug-1.mmd: ${launch}\n`, - ); - assert.match( - r.stdout, - /\nBroken as expected: NeverSyncedWhileOffline\.\n(.*\n)*Wrote chart\.mmd, bug-1\.mmd, stories\.md, summary\.json in Sync-statechart\.\n$/, + writeFileSync( + join(bin, "npx"), + `#!/bin/sh +while [ $# -gt 0 ]; do + case "$1" in -i) chart=$2 ;; -o) png=$2 ;; esac + shift +done +for c in $UNRENDERED; do + case "$chart" in */$c.mmd) echo "${launch}" >&2; exit 1 ;; esac +done +echo png > "$png" +`, + { mode: 0o755 }, ); - assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ - "bug-1.mmd", - "chart.mmd", - "stories.md", - "summary.json", - "tlc", - ]); + for (const [unrendered, status, written] of [ + ["chart bug-1", 2, ["chart.mmd", "bug-1.mmd"]], + ["bug-1", 2, ["chart.mmd", "bug-1.mmd", "chart.png"]], + ["", 0, ["chart.mmd", "bug-1.mmd", "chart.png", "bug-1.png"]], + ]) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("statechart.mjs", args, dir, { + PATH: `${bin}${delimiter}${process.env.PATH}`, + UNRENDERED: unrendered, + }); + assert.equal(r.status, status, `${unrendered}: ${r.stdout}${r.stderr}`); + assert.equal( + r.stderr, + unrendered + .split(" ") + .filter(Boolean) + .map((c) => `could not render Sync-statechart/${c}.mmd: ${launch}\n`) + .join(""), + ); + assert.match(r.stdout, /\nBroken as expected: NeverSyncedWhileOffline\.\n/); + assert.equal( + r.stdout.split("\n").at(-2), + `Wrote ${[...written, "stories.md", "summary.json"].join(", ")} in Sync-statechart.`, + ); + assert.deepEqual( + readdirSync(join(dir, "Sync-statechart")).sort(), + [...written, "stories.md", "summary.json", "tlc"].sort(), + ); + } }, ); @@ -1108,13 +1215,13 @@ test( [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), ["Works today", "Fixed or new with the fix"], ); - assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/m); const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => - t.label.endsWith("with the fix"), + t.label.startsWith("with the fix, "), ); assert.deepEqual( fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), - ["Paying, charged --> Paid, charged : the bank charges you, with the fix"], + ["Paying, charged --> Paid, charged : with the fix, the bank charges you"], ); }, ); @@ -1154,7 +1261,7 @@ test( ); assert.equal( readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), - "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n", + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", ); writeFileSync( @@ -1326,9 +1433,7 @@ test( }, ); -// Each edge as "from --> to : label" with full state labels, and an arrow from a box's border -// expanded to one edge from each state in the box. -function chartEdges(mmd) { +function flatEdges(mmd) { const names = new Map(); const members = new Map(); let box = null; @@ -1402,7 +1507,7 @@ export const step = (a) => ({ Boot: "the app starts", Ask: "you ask", Answer: "t ); assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3); assert.doesNotMatch(flat, /as g\d+ \{/); - assert.deepEqual(chartEdges(boxed), chartEdges(flat)); + assert.deepEqual(flatEdges(boxed), flatEdges(flat)); assert.match(boxed, /^ {2}class s5 wrongPath$/m); assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m); assert.match(boxed, /state "Breaks a rule" as legendWrong/); @@ -1495,7 +1600,7 @@ There are 2 ways to get here in the model. This is the shortest. 1. You press Save. Now: Saving, draft 2. The save times out. Now: Saved, draft -In the agreed chart, step 2 cannot happen, so you stay at: Saving, draft. +In the agreed chart, step 2 (the save times out) cannot happen, so you stay at: Saving, draft. Broken as expected: NoFalseSaved. @@ -1504,12 +1609,15 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. ); const out = join(dir, "SaveCode-statechart"); const chart = readFileSync(join(out, "chart.mmd"), "utf8"); - assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m); assert.deepEqual( [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), ["Works", "Breaks a rule"], ); - assert.match(chart, /^ {2}s3 --> s3 : the save times out, not in theagreed chart$/m); + assert.match( + chart, + /^ {2}s3 --> s3 : in the agreed chart, the savetimes out cannot happen$/m, + ); const bug = readFileSync(join(out, "bug-1.mmd"), "utf8"); assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [ "Editing, draft", @@ -1586,7 +1694,6 @@ export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`; export const step = (a) => ({ Ask: "you ask", Answer: "the reply arrives", Drop: "the app drops the reply", Close: "you close the window", Reopen: "you open the window" })[a]; `; -// Checks Door.tla with one rule, which breaks, and returns what from-spec printed and drew. function runDoor(map) { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync(join(dir, "Door.tla"), doorSpec); @@ -1721,6 +1828,12 @@ test( const run = runDoor(groupMap)("NoLostReply"); const lines = run.chart.split("\n"); assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [ + "---", + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", "stateDiagram-v2", " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", @@ -1845,10 +1958,11 @@ export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); assert.deepEqual( coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)), [ + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", " class legendWrong plainWrong", " class s0 plainWrong", - " class s1 plainWrong", + " class s1 plain", ], ); const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8")); @@ -1882,3 +1996,287 @@ export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); } }, ); + +const order = (name, note, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges, note +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ note = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ note' = ${note} /\\ UNCHANGED charges +Charge == screen = "Paying" /\\ charges < 2 /\\ ${charge} +Next == Pay \\/ Charge +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const chargeAgain = "charges' = charges + 1 /\\ UNCHANGED <>"; +const chargeOnce = `IF charges = 0 THEN charges' = 1 /\\ UNCHANGED <> ELSE screen' = "Paid" /\\ UNCHANGED <>`; +const chargeFirstOnly = "charges = 0 /\\ charges' = 1 /\\ UNCHANGED <>"; + +test( + "the fix changes a bug only at the first step that the reader sees differently", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Order.tla"), order("Order", 0, chargeAgain)); + writeFileSync(join(dir, "Order.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"); + const withFix = (charge, map = payMap) => { + writeFileSync(join(dir, "Order.map.mjs"), map); + writeFileSync(join(dir, "OrderFixed.tla"), order("OrderFixed", 1, charge)); + const r = node( + "statechart.mjs", + ["from-spec", "Order.tla", "--fixed", "OrderFixed.tla"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const chart = readFileSync(join(dir, "Order-statechart", "chart.mmd"), "utf8"); + return { + story: readFileSync(join(dir, "Order-statechart", "stories.md"), "utf8"), + fixEdges: parseMermaid(chart) + .regions.Main.transitions.filter((t) => t.label.includes("with the fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`), + }; + }; + const hidden = withFix(chargeAgain); + assert.deepEqual(hidden.fixEdges, []); + assert.match( + hidden.story, + /^With the fix you see the same screens and steps, so the fix does not change this path\.$/m, + ); + const once = withFix(chargeOnce); + assert.deepEqual(once.fixEdges, [ + "Paying, charged --> Paid, charged : with the fix, the bank charges you", + ]); + assert.match( + once.story, + /^With the fix, step 3 \(the bank charges you\) takes you to: Paid, charged\.$/m, + ); + const unlabeledMap = payMap.replace( + /^export const state = .*$/m, + "export const state = (v) => v.screen;", + ); + const unseen = withFix(chargeOnce, unlabeledMap); + assert.deepEqual(unseen.fixEdges, ["Paying --> Paid : with the fix, the bank charges you"]); + assert.match( + unseen.story, + /^With the fix, after step 1, "the bank charges you" happens and takes you to: Paid\.$/m, + ); + const blocked = withFix(chargeFirstOnly, unlabeledMap); + assert.deepEqual(blocked.fixEdges, []); + assert.match( + blocked.story, + /yet the rule holds, so the state labels do not show what the fix changes/, + ); + }, +); + +const notify = (name, mutedArrive) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, muted, banner, badge, missed, msgs, syncs +vars == <> +Init == screen = "Inbox" /\\ muted = FALSE /\\ banner = FALSE /\\ badge = FALSE + /\\ missed = FALSE /\\ msgs = 0 /\\ syncs = 0 +OpenThread == screen = "Inbox" /\\ screen' = "Thread" /\\ UNCHANGED <> +OpenSettings == screen \\in {"Inbox", "Thread"} /\\ screen' = "Settings" /\\ UNCHANGED <> +TapBack == screen \\in {"Thread", "Settings"} /\\ screen' = "Inbox" /\\ UNCHANGED <> +Mute == screen = "Settings" /\\ ~muted /\\ muted' = TRUE /\\ UNCHANGED <> +Refresh == syncs < 2 /\\ syncs' = syncs + 1 /\\ UNCHANGED <> +Arrive == msgs < syncs /\\ msgs' = msgs + 1 + /\\ (IF muted THEN ${mutedArrive} ELSE banner' = TRUE /\\ UNCHANGED <>) + /\\ UNCHANGED <> +Next == OpenThread \\/ OpenSettings \\/ TapBack \\/ Mute \\/ Refresh \\/ Arrive +Spec == Init /\\ [][Next]_vars +NeverMissed == ~missed +NoBannerOnThread == ~(banner /\\ screen = "Thread") +==== +`; + +test( + "a step the reader does not see breaks the rule, and the fix shows it on another screen", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Notify.tla"), + notify("Notify", "missed' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "NotifyBadge.tla"), + notify("NotifyBadge", "badge' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "Notify.cfg"), + "SPECIFICATION Spec\nINVARIANT NeverMissed\nINVARIANT NoBannerOnThread\n", + ); + writeFileSync( + join(dir, "Notify.map.mjs"), + `export const readerActions = ["OpenThread", "OpenSettings", "TapBack", "Mute", "Refresh"]; +export const state = (v) => [v.screen, v.muted && "muted", v.banner && "banner", v.badge && "badge"].filter(Boolean).join(", "); +export const step = (a) => ({ OpenThread: "you open the thread", OpenSettings: "you open settings", TapBack: "you tap Back", Mute: "you mute alerts", Refresh: "you pull to refresh", Arrive: "a message arrives" })[a]; +export const cause = () => null; +`, + ); + const r = node( + "statechart.mjs", + ["from-spec", "Notify.tla", "--fixed", "NotifyBadge.tla", "--label", "the badge fix"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Notify-statechart", f), "utf8"); + const fixEdges = (f) => + parseMermaid(out(f)) + .regions.Main.transitions.filter((t) => t.label.includes("with the badge fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`); + const { classes } = JSON.parse(out("summary.json")); + assert.deepEqual( + classes.map((c) => [c.invariant, c.end, c.fix]), + [ + ["NoBannerOnThread", "Thread, banner", "unchanged"], + ["NeverMissed", "Settings, muted", "Settings, muted, badge"], + ["NeverMissed", "Inbox, muted", "Inbox, muted, badge"], + ["NeverMissed", "Settings, muted, banner", "Settings, muted, banner, badge"], + ["NeverMissed", "Thread, muted", "Thread, muted, badge"], + ["NoBannerOnThread", "Thread, muted, banner", "unchanged"], + ["NeverMissed", "Inbox, muted, banner", "Inbox, muted, banner, badge"], + ["NeverMissed", "Thread, muted, banner", "Thread, muted, banner, badge"], + ["NoBannerOnThread", "Thread, muted, banner, badge", "new with the badge fix"], + ], + ); + const missed = classes.filter((c) => c.invariant === "NeverMissed"); + + const story = out("stories.md"); + assert.doesNotMatch(story, /do not show what/); + assert.match( + story, + /^With the badge fix, after step 3, "a message arrives" happens and takes you to: Settings, muted, badge\.$/m, + ); + const settings = classes.find((c) => c.end === "Settings, muted"); + assert.deepEqual(fixEdges(`bug-${settings.n}.mmd`), [ + "Settings, muted --> Settings, muted, badge : with the badge fix, a message arrives", + ]); + const drawn = fixEdges("chart.mmd"); + assert.equal(new Set(drawn).size, drawn.length); + for (const c of missed) + assert.ok( + drawn.includes(`${c.end} --> ${c.end}, badge : with the badge fix, a message arrives`), + `bug ${c.n}`, + ); + }, +); + +test( + "each looping step on a bug path keeps its own label, a null cause groups bugs by end state, and the mapping names the key's colors", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Browse.tla"), + `---- MODULE Browse ---- +EXTENDS Naturals +VARIABLES screen, looks, scrolled +vars == <> +Init == screen = "Cart" /\\ looks = 0 /\\ scrolled = FALSE +Look == screen = "Cart" /\\ looks < 3 /\\ looks' = looks + 1 /\\ UNCHANGED <> +Scroll == screen = "Cart" /\\ looks = 1 /\\ ~scrolled /\\ scrolled' = TRUE /\\ UNCHANGED <> +Pay == screen = "Cart" /\\ looks = 3 /\\ scrolled /\\ screen' = "Paying" /\\ UNCHANGED <> +Fail == screen = "Paying" /\\ screen' = "Failed" /\\ UNCHANGED <> +Hang == screen = "Paying" /\\ screen' = "Stuck" /\\ UNCHANGED <> +Next == Look \\/ Scroll \\/ Pay \\/ Fail \\/ Hang +Spec == Init /\\ [][Next]_vars +NeverLost == screen \\notin {"Failed", "Stuck"} +==== +`, + ); + writeFileSync(join(dir, "Browse.cfg"), "SPECIFICATION Spec\nINVARIANT NeverLost\n"); + writeFileSync( + join(dir, "Browse.map.mjs"), + `export const readerActions = ["Look", "Scroll", "Pay"]; +export const state = (v) => v.screen; +export const step = (a) => ({ Look: "you look", Scroll: "you scroll", Pay: "you pay", Fail: "the payment fails", Hang: "the payment hangs" })[a]; +export const cause = () => null; +export const legend = { works: "Works on main", wrong: "Wrong on main" }; +`, + ); + const r = node("statechart.mjs", ["from-spec", "Browse.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Browse-statechart", f), "utf8"); + assert.deepEqual( + JSON.parse(out("summary.json")).classes.map((c) => [c.end, c.cause]), + [ + ["Failed", null], + ["Stuck", null], + ], + ); + assert.doesNotMatch(out("stories.md"), /^Why:|null/m); + const loops = (chart) => [...chart.matchAll(/^ {2}(s\d+) --> \1 : (.*)$/gm)].map((m) => m[2]); + assert.deepEqual(loops(out("bug-1.mmd")), ["1. you look
2. you scroll
3, 4. you look"]); + assert.deepEqual(loops(out("chart.mmd")), ["you look or you scroll"]); + const chart = out("chart.mmd"); + assert.ok(chart.includes('state "Works on main" as legendWorks')); + assert.ok(chart.includes('state "Wrong on main" as legendWrong')); + assert.doesNotMatch(chart, /legendFixed/); + }, +); + +test("a rule that stays broken reports only the step that broke it", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Buzz.tla"), + `---- MODULE Buzz ---- +EXTENDS Naturals +VARIABLES x, y, buzzed +vars == <> +Init == x = 0 /\\ y = 0 /\\ buzzed = FALSE +IncX == x < 6 /\\ x' = x + 1 /\\ UNCHANGED <> +IncY == y < 6 /\\ y' = y + 1 /\\ UNCHANGED <> +Ring == x = 2 /\\ ~buzzed /\\ buzzed' = TRUE /\\ UNCHANGED <> +Next == IncX \\/ IncY \\/ Ring +Spec == Init /\\ [][Next]_vars +NeverBuzzed == ~buzzed +==== +`, + ); + writeFileSync(join(dir, "Buzz.cfg"), "SPECIFICATION Spec\nINVARIANT NeverBuzzed\n"); + writeFileSync( + join(dir, "Buzz.map.mjs"), + 'export const state = (v) => (v.buzzed ? "Buzzed" : "Quiet");\nexport const step = () => "time passes";\n', + ); + const r = node("statechart.mjs", ["from-spec", "Buzz.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const traces = parseTraces( + readFileSync(join(dir, "Buzz-statechart", "tlc", "Buzz-NeverBuzzed.out"), "utf8"), + ); + assert.equal(traces.length, 7); + for (const t of traces) + assert.equal( + t.steps.findIndex((s) => s.vars.buzzed), + t.steps.length - 1, + ); +}); + +test("from-spec warns when steps carry the name of the whole next-state action", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const count = (next) => `---- MODULE Count ---- +EXTENDS Naturals +VARIABLES x +Init == x = 0 +Inc == x < 3 /\\ x' = x + 1 +${next} +Spec == \\* not [][Inc]_x + Init /\\ [][Next]_x +==== +`; + writeFileSync(join(dir, "Count.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Count.map.mjs"), "export const state = (v) => String(v.x);\n"); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc \\/ (x = 3 /\\ x' = 0)")); + const unnamed = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(unnamed.status, 0, unnamed.stderr); + assert.match( + unnamed.stderr, + /^Count\.tla: 1 step has the name Next, the whole next-state action/, + ); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc")); + const named = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(named.status, 0, named.stderr); + assert.equal(named.stderr, ""); +}); diff --git a/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/pstack/skills/statechart/scripts/tlc-to-chart.mjs index d5a3f4c33..a933666d0 100644 --- a/pstack/skills/statechart/scripts/tlc-to-chart.mjs +++ b/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; import { + FULL_LABELS, GROUP_PALETTE, LEGEND_ID, PLAIN_FILL, @@ -12,6 +13,7 @@ import { groupClassDef, groupColorProblem, isRecord, + joinLabels, parseDot, parseMermaid, parseTraces, @@ -35,26 +37,29 @@ statechart.mjs runs it once for the overview and once for each bug chart. optionally detail(vars, facts), step(action, before, after), guard(action, before, after), timer(action, before, after), cause(vars, facts, invariant), group(vars, facts), groupColors, - regionNames, and readerActions. guard is when the step + regionNames, readerActions, and legend. guard is when the step can happen, in plain words. An edge reads "step [words]" when every model step behind it has words. timer is how many seconds the code waits before a timer step, and the step reads "after N s, step". cause is the hidden reason a bad state is bad: counterexamples with the - same invariant and cause are one bug. readerActions are the actions the - reader takes. group is the group of a state, below. + same invariant and cause are one bug. When cause returns nothing, the + end state stands in for it. readerActions are the actions the + reader takes. group is the group of a state, below. legend, such as + { works: "Works on main" }, gives the legend's words for the works, + wrong, and fixed statuses. facts.enabled is the set of actions the state enables. facts.settled is true when only reader actions are enabled, so nothing else happens until the reader acts. --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one per bad state. It refuses a file from a TLC run that stopped early or hit an error other than a broken rule. - --fixed-dot FILE The state graph of the model with the open fix. Counterexamples replay on - it. + --fixed-dot FILE The state graph of the model with the open fix. Each bug's steps replay + on it. --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks states fixed or still wrong, and adds the bugs that only the fixed model has after the current model's bugs. --against-dot FILE The state graph of the spec that the current code must follow. - Counterexamples replay on it. Do not use it with --fixed-dot. + Each bug's steps replay on it. Do not use it with --fixed-dot. --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the bugs that only the spec has number after the code's bugs. --label TEXT The name of the second model in the chart and the stories. Default: @@ -67,7 +72,9 @@ statechart.mjs runs it once for the overview and once for each bug chart. counterexample paths. --highlight N Draw bug N as a numbered path. Default 1. 0 draws none. --path-only Draw only the highlighted path. - --all-labels Write every label on an edge. Default: the two most common, then " or …". + --full-labels Write each label on an edge in full, joined by " or ", and mark the chart + "${FULL_LABELS}". Default: write the leading words that the labels + share once, as in "you tap Pay or Back". --flat Draw every state on its own, with no boxes. --regions When every state reads "context: progress", draw two parallel regions, context and progress, when they are smaller and proved. They draw the @@ -83,6 +90,14 @@ statechart.mjs runs it once for the overview and once for each bug chart. --summary FILE The counts for the chart and the bugs, as JSON. -h, --help Print this help, and exit 0. +Each bug replays on the second model by its screens and step words, which are what the reader +sees. The replay takes the second model's steps that keep the screen and that the reader does not +take. A bug step that the reader does not see must keep the screen on the second model too, unless +the second model cannot take it. The replay stops at the first step that the second model shows +the reader differently. There the chart draws "with LABEL, STEP" (or "in LABEL, STEP" for +--against-dot) to where the second model goes, or a loop "... STEP cannot happen" when it cannot +take a step that the reader sees. + The second model may have other variables and actions than the current model. Variables and record fields that one start state has and the other does not are left out when the two models are compared. The second model's states take the current model's start values for the variables @@ -107,8 +122,12 @@ a step that they make wrongly gets back every arrow that the full regions take t compare-charts.mjs on the --regions-all and --proof files repeats the first proof. mapping.regionNames = [context, progress] names the two regions. +Outside regions, two states have at most one arrow. Its lines are the numbered steps, then the +current model's steps, then the second model's steps, with a blank line between each group. + Without group(), the legend shows only the statuses the chart uses. A chart with a second model -has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. +has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. Each chart +starts with front matter that keeps Mermaid from shrinking it to the page or rewrapping its labels. Groups: group(vars, facts) returns the name of the group that a model state is in, such as "Toast shown", or nothing. A chart state is in a group when all its model states are in it. A @@ -160,7 +179,7 @@ for (let i = 2; i < process.argv.length; i++) { else if (a === "--view") args.view = next(); else if (a === "--highlight") args.highlight = Number(next()); else if (a === "--path-only") args.pathOnly = true; - else if (a === "--all-labels") args.allLabels = true; + else if (a === "--full-labels") args.fullLabels = true; else if (a === "--flat") args.flat = true; else if (a === "--regions") args.regions = true; else if (a === "--proof") args.proof = next(); @@ -227,9 +246,9 @@ function keyOf(vars, facts) { throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; } -function stepLabel(action, before, after) { +function stepLabel(action, before, after, view = args.view) { const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; - const named = args.view === "customer" || said === action ? said : `${said} [${action}]`; + const named = view === "customer" || said === action ? said : `${said} [${action}]`; const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null; if (seconds === undefined || seconds === null || seconds === "" || !named) return named; if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) @@ -324,7 +343,7 @@ function quotient(m) { edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); } - // Most common wording first. A chart edge shows two unless --all-labels. + // Most common wording first. for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); const settled = new Set( @@ -358,21 +377,26 @@ if (secondGraph) { // Reader path of one trace: drop system steps that keep the reader in the same state. // Reader actions stay even then, because the reader did them. -function readerPath(trace, key) { +function readerPath(trace, key, labelOf = stepLabel) { const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; for (let i = 1; i < trace.steps.length; i++) { + const { action } = trace.steps[i]; const k = key(trace.steps[i].vars); - if (k === out[out.length - 1].key && !readerActions.has(trace.steps[i].action)) continue; + if (k === out[out.length - 1].key && !readerActions.has(action)) continue; const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; - const label = stepLabel(trace.steps[i].action, before, after) || trace.steps[i].action; - const guard = stepGuard(trace.steps[i].action, before, after); - out.push({ key: k, label, guard, modelStep: i }); + const label = labelOf(action, before, after) || action; + const guard = stepGuard(action, before, after); + out.push({ key: k, label, guard, action, modelStep: i }); } return out; } -// Replay a counterexample on the second model, comparing only the variables both models have. -// Returns where the second model leaves the counterexample. +const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer"); +// Replay a bug on the second model, step by step. The reader sees the screens and step words of its +// reader path. The reader does not see a step that keeps the screen and that they do not take, so +// the second model must keep the screen at that step too, or be unable to take it. Returns the +// first step that the second model shows the reader differently, or, when it shows them all the +// same way, whether a state on the way breaks the same rule there. function replaySecond(trace) { if (!second) return null; const shared = (vars) => stateKey(omit(vars, lacks)); @@ -384,41 +408,76 @@ function replaySecond(trace) { } const actions = new Set([...secondGraph.edges, ...secondGraph.loops].map((e) => e.action)); const nodeOf = (id) => secondGraph.nodes.get(id); - let cur = [...secondGraph.nodes.values()].find( - (n) => n.init && sharedOf(n) === shared(trace.steps[0].vars), + const screen = new Map( + [...secondGraph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), + ); + const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action; + const settle = (ids) => { + const all = new Set(ids); + for (const id of all) + for (const e of out.get(id) ?? []) + if (!readerActions.has(e.action) && screen.get(e.to) === screen.get(id)) all.add(e.to); + return all; + }; + const seen = readerPath(trace, main.customer, readerLabel); + const shownAt = new Map(seen.map((p, j) => [p.modelStep, j])); + let here = settle( + [...secondGraph.nodes.values()] + .filter((n) => n.init && screen.get(n.id) === seen[0].key) + .map((n) => n.id), ); - if (!cur) return { step: 0, reason: "starts somewhere else" }; + if (here.size === 0) return { reason: "starts on another screen" }; + const passed = new Set(here); + let j = 0; for (let i = 1; i < trace.steps.length; i++) { - const want = trace.steps[i]; - const target = shared(want.vars); - const from = out.get(cur.id) ?? []; - const exact = from.find((e) => e.action === want.action && sharedOf(nodeOf(e.to)) === target); - if (exact) { - cur = nodeOf(exact.to); + const { action } = trace.steps[i]; + const hidden = !shownAt.has(i); + if (!hidden) j = shownAt.get(i); + const want = seen[j]; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const known = actions.has(action); + if (!known && (hidden || shared(before) === shared(after))) continue; + const tries = [...here].flatMap((id) => + (out.get(id) ?? []).filter((e) => + known ? e.action === action : sharedOf(nodeOf(e.to)) === shared(after), + ), + ); + if (hidden && tries.length === 0) continue; + const same = known + ? tries.filter((e) => screen.get(e.to) === want.key && (hidden || words(e) === want.label)) + : tries; + if (same.length) { + here = settle(same.map((e) => e.to)); + for (const id of here) passed.add(id); continue; } - if (!actions.has(want.action)) { - if (target === sharedOf(cur)) continue; - const any = from.find((e) => sharedOf(nodeOf(e.to)) === target); - if (any) { - cur = nodeOf(any.to); - continue; - } + const outcomes = new Map(); + for (const e of tries) { + const id = `${screen.get(e.to)}${SEP}${words(e)}`; + outcomes.set(id, [...(outcomes.get(id) ?? []), e]); } - const wanted = omit(want.vars, lacks); - const score = (n) => { - const have = omit(n.raw, extra); - return Object.keys(wanted).filter((k) => stateKey(have[k]) === stateKey(wanted[k])).length; - }; - const best = from - .filter((e) => e.action === want.action) - .map((e) => nodeOf(e.to)) - .sort((a, b) => score(b) - score(a))[0]; - const before = trace.steps[i - 1].vars; - const vars = fill((best ?? cur).raw, lacks, before); - return { step: i, action: want.action, disabled: !best, key: second.key(vars), vars, before }; + const taken = [...outcomes.values()].sort((a, b) => b.length - a.length)[0]?.[0]; + const from = main.key(before); + const said = hidden ? readerLabel(action, before, after) || action : want.label; + const at = { step: j, said, from, hidden }; + if (!taken) + return { + ...at, + key: from, + label: stepLabel(action, before, after) || action, + disabled: true, + }; + const [b, a] = [nodeOf(taken.from).vars, nodeOf(taken.to).vars]; + return { ...at, key: second.key(a), label: stepLabel(taken.action, b, a) || taken.action }; } - return { step: trace.steps.length, sameEnd: true }; + const bad = new Set( + rawSecondTraces + .filter((t) => t.invariant === trace.invariant) + .map((t) => stateKey(t.steps[t.steps.length - 1].vars)), + ); + const ruleHolds = + secondTraceFiles.length > 0 && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars))); + return { sameEnd: true, ruleHolds }; } function readTraces(files, paths = []) { @@ -463,10 +522,11 @@ function group(cuts, m) { for (const trace of cuts) { const rp = readerPath(trace, m.customer); const last = trace.steps[trace.steps.length - 1].vars; - const cause = + const said = typeof mapping.cause === "function" - ? String(mapping.cause(last, m.facts(last), trace.invariant)) + ? mapping.cause(last, m.facts(last), trace.invariant) : null; + const cause = said === undefined || said === null ? null : String(said); const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; const prev = byId.get(id); if ( @@ -511,12 +571,14 @@ for (const c of introduced) { classes.push(...introduced); // ---------- status ---------- -// A chart state is bad when it holds a state that breaks a rule, or when every path from the start to -// it passes such a state: what the reader sees after a bug is part of that bug. +// A chart state is bad when it holds a state where a rule breaks, or when every path from the start +// to it passes such a state: what the reader sees after a bug is part of that bug. TLC stops each +// path at the first state that breaks the rule, so a later state that breaks it again does not +// color its chart state when a path without a bug also reaches that chart state. // With a fix, a state is still wrong when it is bad in the fixed model, and fixed when it is bad only // in the current model; without --fixed-traces every bad state is wrong. // Against a spec, a state is wrong when it is bad in either model, and green when only the spec has it. -const diverges = (c) => c.second?.key !== undefined && c.second.key !== c.end; +const diverges = (c) => c.second?.key !== undefined; function badKeys(m, keys, raw) { const g = m.graph; const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); @@ -548,7 +610,6 @@ const badSecond = ? badNow : new Set(); const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); -for (const c of classes) if (diverges(c)) allNodes.add(c.second.key); const onlySecond = (k) => second && !now.nodes.has(k); const status = (k) => { if (role === "against") @@ -560,7 +621,7 @@ const status = (k) => { const edges = new Map(now.edges); if (secondQ) { - const only = (l) => `${l} (${within} ${L})`; + const only = (l) => `${within} ${L}, ${l}`; for (const [id, e] of secondQ.edges) { if (!edges.has(id)) edges.set(id, { @@ -571,33 +632,22 @@ if (secondQ) { }); } } -// The second model changes one model step of the counterexample. Its edge leaves the chart state +// The second model shows the reader one step of the bug differently. Its edge leaves the chart state // before that step and ends where the second model goes instead, or stays put when the second model -// cannot take the step. -const secondEdgeId = (c) => `${c.second.from}${SEP}${SEP}${c.second.key}${SEP}second`; +// cannot take the step. A step that the chart already draws there, in the same words, adds no edge. for (const c of classes) { if (!diverges(c)) continue; - const { action, before } = c.second; - const after = c.trace.steps[c.second.step].vars; - c.second.from = main.key(before); - c.second.said = stepLabel(action, before, after) || action; - c.second.said2 = stepLabel(action, before, c.second.vars) || c.second.said; - const id = secondEdgeId(c); - if (!edges.has(id)) - edges.set(id, { - from: c.second.from, - to: c.second.key, - labels: new Set(), - secondOnly: true, - secondOf: new Set(), - }); - const blocked = role === "fix" ? `blocked by ${L}` : `not in ${L}`; - edges - .get(id) - .labels.add( - c.second.disabled ? `${c.second.said}, ${blocked}` : `${c.second.said2}, ${within} ${L}`, - ); - edges.get(id).secondOf.add(c); + const s = c.second; + const plain = `${s.from}${SEP}${SEP}${s.key}`; + s.text = `${within} ${L}, ${s.label}${s.disabled ? " cannot happen" : ""}`; + if (!s.disabled && [s.label, s.text].some((l) => edges.get(plain)?.labels.has(l))) { + s.edge = plain; + continue; + } + s.edge = `${plain}${SEP}second`; + if (!edges.has(s.edge)) + edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); + edges.get(s.edge).labels.add(s.text); } // ---------- highlighted path ---------- @@ -609,13 +659,10 @@ const pathNumbers = new Map(); if (chosen) { chosen.path.slice(1).forEach((p, i) => { const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; - if (!pathNumbers.has(id)) - pathNumbers.set(id, { - n: [], - label: p.label, - shown: p.guard ? `${p.label} [${p.guard}]` : p.label, - }); - pathNumbers.get(id).n.push(i + 1); + if (!pathNumbers.has(id)) pathNumbers.set(id, []); + pathNumbers + .get(id) + .push({ n: i + 1, label: p.label, shown: p.guard ? `${p.label} [${p.guard}]` : p.label }); if (!edges.has(id)) edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); }); @@ -652,18 +699,19 @@ if (args.cover) { secondQ, [...secondQ.settled].filter((k) => !now.nodes.has(k)), ); + const pathLabels = new Map(); for (const c of classes) { - c.path.slice(1).forEach((p, i) => keepEdges.add(`${c.path[i].key}${SEP}${SEP}${p.key}`)); - if (diverges(c)) keepEdges.add(secondEdgeId(c)); + c.path.slice(1).forEach((p, i) => { + const id = `${c.path[i].key}${SEP}${SEP}${p.key}`; + keepEdges.add(id); + pathLabels.set(id, (pathLabels.get(id) ?? new Set()).add(p.label)); + }); + if (diverges(c)) keepEdges.add(c.second.edge); } for (const id of keepEdges) { if (!edges.has(id)) { const [from, to] = id.split(`${SEP}${SEP}`); - const c = classes.find((x) => - x.path.some((p, i) => i > 0 && x.path[i - 1].key === from && p.key === to), - ); - const step = c?.path.find((p, i) => i > 0 && c.path[i - 1].key === from && p.key === to); - edges.set(id, { from, to, labels: new Set([step?.label ?? ""]) }); + edges.set(id, { from, to, labels: pathLabels.get(id) ?? new Set() }); } } coverEdges = keepEdges; @@ -671,9 +719,15 @@ if (args.cover) { // ---------- Mermaid ---------- const LEGEND = { - works: ["legendWorks", role === "fix" ? "Works today" : "Works"], - wrong: ["legendWrong", role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"], - fixed: ["legendFixed", role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`], + works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")], + wrong: [ + "legendWrong", + mapping.legend?.wrong ?? (role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"), + ], + fixed: [ + "legendFixed", + mapping.legend?.fixed ?? (role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`), + ], }; // ---------- groups ---------- @@ -696,7 +750,6 @@ if (grouping) { for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); if (second) for (const n of secondGraph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); - for (const c of classes) if (diverges(c)) add(c.second.key, c.second.vars, second); const given = mapping.groupColors ?? {}; if (!isRecord(given)) @@ -754,12 +807,22 @@ const groupCounts = (drawn) => })) .filter((g) => g.states > 0); +// Mermaid shrinks a chart to the page width unless useMaxWidth is false, and rewraps labels that +// wrap() already wrapped unless markdownAutoWrap is false. +const frontMatter = (title) => [ + "---", + ...(title ? [`title: ${JSON.stringify(title)}`] : []), + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", +]; // drawn holds { group, status, cls } for each state the chart draws. function head(drawn) { - const out = []; - if (args.title) out.push("---", `title: ${JSON.stringify(args.title)}`, "---"); - out.push("stateDiagram-v2"); + const out = [...frontMatter(args.title), "stateDiagram-v2"]; if (role) out.push(` %% second model (${role}): ${L}`); + if (args.fullLabels) out.push(` ${FULL_LABELS}`); const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s)); const legend = grouping ? [ @@ -821,6 +884,7 @@ function regionsChart() { }; const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`])); const proof = [ + ...frontMatter(), "stateDiagram-v2", ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), @@ -991,7 +1055,7 @@ function regionsChart() { const e = edges.get(`${c.path[j].key}${SEP}${SEP}${p.key}`); if (e) stepsOf(e, norm(p.label)).forEach(want); }); - if (diverges(c)) stepsOf(edges.get(secondEdgeId(c)), "").forEach(want); + if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want); } const cheapest = () => { const cost = new Map(starts.map((k) => [bar(split(k)), 0])); @@ -1122,26 +1186,61 @@ for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); const caption = (k) => args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; -let edgeCount = 0; +// Consecutive steps of one arrow with the same words share a line, as in "2, 3. you look". +function numbered(steps) { + const runs = []; + for (const { n, shown } of steps) { + const last = runs.at(-1); + if (last?.shown === shown && last.n.at(-1) === n - 1) last.n.push(n); + else runs.push({ shown, n: [n] }); + } + return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`); +} const drawnLabels = new Set(); -const lines = []; +// Mermaid stacks the loops on a state in one place, so two states get one arrow. A blank line sets +// its numbered steps, the current model's steps, and the second model's steps apart, since a +// wrapped line looks like a new one. The blank line holds a no-break space, because Mermaid drops +// an empty line. +const arrows = new Map(); for (const [id, e] of edges) { if (!keep.has(e.from) || !keep.has(e.to)) continue; - if (args.pathOnly && chosen && !pathNumbers.has(id) && !e.secondOf?.has(chosen)) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue; if (coverEdges && !coverEdges.has(id)) continue; + const pair = `${e.from}${SEP}${SEP}${e.to}`; + if (!arrows.has(pair)) + arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] }); + const a = arrows.get(pair); + const [numbers, current, other] = a.groups; const all = [...e.labels]; const shown = all.map((l) => guarded(e.guards, l)); - let text = args.allLabels - ? shown.join(" or ") - : shown.slice(0, 2).join(" or ") + (all.length > 2 ? " or …" : ""); + const said = args.fullLabels ? shown.join(" or ") : joinLabels(shown); const p = pathNumbers.get(id); - if (p) text = `${p.n.join(", ")}. ${p.shown}`; - for (const l of p ? [p.label] : all) drawnLabels.add(l); - // ";" ends a Mermaid statement, even inside a transition label. - text = text.replaceAll(";", ","); - lines.push({ from: e.from, to: e.to, text, plain: !p && !e.secondOf, labels: [...all].sort() }); - edgeCount++; + const chosenStep = chosen?.second && id === chosen.second.edge; + if (p) numbers.push(...numbered(p)); + else if (!e.secondOnly) current.push(said); + else other.push(args.pathOnly && chosenStep ? chosen.second.text : said); + if (chosenStep && !e.secondOnly) other.push(chosen.second.text); + if (p || chosenStep || id.endsWith(`${SEP}second`)) a.plain = false; + a.labels.push(...all); + for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l); } +const lines = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ + from, + to, + // ";" ends a Mermaid statement, even inside a transition label. + text: groups + .map((g) => + g + .filter(Boolean) + .map((l) => wrap(l.replaceAll(";", ","), 30)) + .join("
"), + ) + .filter(Boolean) + .join("
#160;
"), + plain, + labels: labels.sort(), +})); +const edgeCount = lines.length; // One arrow from a box's border stands for the same step from every state in the box. It is drawn // only when every state in the box has that step, with the same labels, to the same state outside // the box, so the chart still says exactly what each state does. @@ -1174,7 +1273,7 @@ const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); -const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${wrap(l.text, 30)}` : ""}`; +const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`; for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); for (const l of lines) out.push(arrow(idOf(l.from), l)); sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); @@ -1185,27 +1284,26 @@ else process.stdout.write(mermaid); // ---------- stories and summary ---------- const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; -// The reader step of a model step, or the last reader step before it when the reader saw no change. -function readerStep(c, step) { - const at = c.path.findIndex((p) => p.modelStep === step); - if (at >= 0) return { at }; - return { after: c.path.findLastIndex((p) => p.modelStep < step) }; -} function secondLine(c) { const W = cap(within); if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`; const s = c.second; if (!s) return null; - if (s.sameEnd) return `${W} ${L}, the same steps end in the same place.`; + if (s.sameEnd) + return s.ruleHolds + ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.` + : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`; if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; - const { at, after } = readerStep(c, s.step); - if (s.disabled) - return at !== undefined - ? `${W} ${L}, step ${at} cannot happen, so you stay at: ${show(s.key)}.` - : `${W} ${L}, this path ends after step ${after}, and you stay at: ${show(s.key)}.`; - return at !== undefined - ? `${W} ${L}, step ${at} is "${s.said2}", and you see: ${show(s.key)}.` - : `${W} ${L}, after step ${after}, "${s.said2}" happens, and you see: ${show(s.key)}.`; + const outcome = s.disabled + ? "cannot happen, so you stay at" + : s.key === s.from + ? "keeps you at" + : "takes you to"; + if (s.hidden) { + const when = s.step ? `after step ${s.step}` : "before your first step"; + return `${W} ${L}, ${when}, "${s.said}" happens and ${outcome}: ${show(s.key)}.`; + } + return `${W} ${L}, step ${s.step} (${s.said}) ${outcome}: ${show(s.key)}.`; } if (args.story) { const lines = []; @@ -1246,7 +1344,9 @@ const secondOf = (c) => : `only in ${L}` : c.second ? c.second.sameEnd - ? "unchanged" + ? c.second.ruleHolds + ? "same screens, but the rule holds" + : "unchanged" : (c.second.reason ?? show(c.second.key)) : null; const summary = { From a0f6e3a9cc20efd8b255a61775ee7aea35313714 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 05:29:29 +0000 Subject: [PATCH 8/9] Update the statechart skill: safe output folder, its TLA+ model, and the comment and assignment cleanups Co-authored-by: lauren --- .../statechart/scripts/chart-to-tla.mjs | 60 +- .../statechart/scripts/compare-charts.mjs | 21 +- .../skills/statechart/scripts/lib.mjs | 76 +-- .../statechart/scripts/model/OutputFolder.cfg | 6 + .../scripts/model/OutputFolder.map.mjs | 106 ++++ .../statechart/scripts/model/OutputFolder.tla | 176 ++++++ .../skills/statechart/scripts/statechart.mjs | 262 ++++++--- .../scripts/test/statechart.test.mjs | 528 +++++++++++++++++- .../statechart/scripts/tlc-to-chart.mjs | 352 +++++------- .../statechart/scripts/chart-to-tla.mjs | 60 +- .../statechart/scripts/compare-charts.mjs | 21 +- pstack/skills/statechart/scripts/lib.mjs | 76 +-- .../statechart/scripts/model/OutputFolder.cfg | 6 + .../scripts/model/OutputFolder.map.mjs | 106 ++++ .../statechart/scripts/model/OutputFolder.tla | 176 ++++++ .../skills/statechart/scripts/statechart.mjs | 262 ++++++--- .../scripts/test/statechart.test.mjs | 528 +++++++++++++++++- .../statechart/scripts/tlc-to-chart.mjs | 352 +++++------- 18 files changed, 2378 insertions(+), 796 deletions(-) create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.cfg create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.map.mjs create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.tla create mode 100644 pstack/skills/statechart/scripts/model/OutputFolder.cfg create mode 100644 pstack/skills/statechart/scripts/model/OutputFolder.map.mjs create mode 100644 pstack/skills/statechart/scripts/model/OutputFolder.tla diff --git a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs index dacbedd94..7dfa337d2 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/chart-to-tla.mjs @@ -81,7 +81,6 @@ const vars = Object.entries(regions).map(([name, r]) => { return { name, v: ident(name, true), ...r }; }); -// A guard reads the other regions in the state before the step. function guardOf(g) { if (!g) return null; const parts = Object.entries(g.states).map(([name, states]) => { @@ -91,7 +90,6 @@ function guardOf(g) { return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ "); } -// One action per label. Unlabeled transitions get one action each. const actions = []; const byLabel = new Map(); for (const [ri, r] of vars.entries()) { @@ -115,7 +113,7 @@ for (const a of actions) { } const out = []; -const bullet = (indent, op, lines) => lines.map((l, i) => `${" ".repeat(indent)}${op} ${l}`); +const conjuncts = (lines) => lines.map((l) => ` /\\ ${l}`); out.push(`---- MODULE ${args.module} ----`); out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); out.push("\\* Each region is one variable and each transition label is one action."); @@ -130,60 +128,42 @@ out.push(""); for (const x of vars) out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); out.push(""); -out.push( - "TypeOK ==", - ...bullet( - 4, - "/\\", - vars.map((x) => `${x.v} \\in ${x.v}States`), - ), -); +out.push("TypeOK ==", ...conjuncts(vars.map((x) => `${x.v} \\in ${x.v}States`))); out.push(""); out.push( "Init ==", - ...bullet( - 4, - "/\\", + ...conjuncts( vars.map((x) => x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`, ), ), ); +const enabled = (v, m) => `${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""}`; +function whetherTakes(a, ri) { + const { v } = vars[ri]; + const moves = a.moves.get(ri); + if (moves.every((m) => !m.when)) { + const from = set(new Set(moves.map((m) => m.from))); + return { can: `${v} \\in ${from}`, cannot: `${v} \\notin ${from}` }; + } + const any = moves.map((m) => `(${enabled(v, m)})`).join(" \\/ "); + return { can: any, cannot: `~(${any})` }; +} for (const a of actions) { out.push(""); if (a.label) out.push(`\\* ${a.label}`); out.push(`${a.name} ==`); const moving = [...a.moves.keys()]; - // Whether region ri can take the action, or with `not`, cannot. - const can = (ri, not) => { - const v = vars[ri].v; - const moves = a.moves.get(ri); - if (moves.every((m) => !m.when)) - return `${v} ${not ? "\\notin" : "\\in"} ${set(new Set(moves.map((m) => m.from)))}`; - const any = moves - .map((m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""})`) - .join(" \\/ "); - return not ? `~(${any})` : any; - }; - const conj = []; - if (moving.length > 1) conj.push(`(${moving.map((ri) => can(ri, false)).join(" \\/ ")})`); - const blocks = []; + const shared = moving.length > 1; + if (shared) out.push(` /\\ (${moving.map((ri) => whetherTakes(a, ri).can).join(" \\/ ")})`); for (const ri of moving) { - const v = vars[ri].v; - const ds = a.moves - .get(ri) - .map( - (m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""} /\\ ${v}' = ${str(m.to)})`, - ); - if (moving.length > 1) ds.push(`(${can(ri, true)} /\\ UNCHANGED ${v})`); - blocks.push(ds); - } - const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); - for (const c of conj) out.push(` /\\ ${c}`); - for (const ds of blocks) { + const { v } = vars[ri]; + const ds = a.moves.get(ri).map((m) => `(${enabled(v, m)} /\\ ${v}' = ${str(m.to)})`); + if (shared) ds.push(`(${whetherTakes(a, ri).cannot} /\\ UNCHANGED ${v})`); out.push(` /\\ \\/ ${ds[0]}`); for (const d of ds.slice(1)) out.push(` \\/ ${d}`); } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); } out.push(""); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs index 3ddb3311a..587125a9f 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/compare-charts.mjs @@ -83,19 +83,14 @@ for (const [bad, why] of textRefusals) { } const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions); const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions); -const drawn = Object.values(actualRegions).flatMap((r) => r.transitions); - -const refusals = [ - [ - (l) => /^\d+(, \d+)*\. /.test(l), - "an edge label is a numbered path step; draw ACTUAL with --highlight 0", - ], -]; -for (const [bad, why] of refusals) { - if (drawn.some((t) => bad(t.label))) { - console.error(`${actualFile}: ${why}`); - process.exit(2); - } +const numbered = Object.values(actualRegions) + .flatMap((r) => r.transitions) + .some((t) => /^\d+(, \d+)*\. /.test(t.label)); +if (numbered) { + console.error( + `${actualFile}: an edge label is a numbered path step; draw ACTUAL with --highlight 0`, + ); + process.exit(2); } const rows = compareCharts(expectedRegions, actualRegions); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index 090c1a8d1..cf8d03823 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -1,9 +1,6 @@ -// Shared code for the statechart scripts. Node built-ins only. import fs from "node:fs"; import process from "node:process"; -// ---------- Where TLC is ---------- - // tla2tools.jar and the java binary, from TLA2TOOLS and JAVA. Throws where to get TLC when TLA2TOOLS is not set. export function findTlc() { const { TLA2TOOLS, JAVA } = process.env; @@ -15,8 +12,6 @@ export function findTlc() { return { jar: TLA2TOOLS, java: JAVA || "java" }; } -// ---------- TLA+ values as TLC prints them ---------- - export function parseValue(text) { const src = text.trim(); let i = 0; @@ -112,7 +107,7 @@ export function parseValue(text) { } // One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. -export function parseState(text) { +function parseState(text) { const lines = text.split("\n").filter((l) => l.trim() !== ""); const conjuncts = []; for (const line of lines) { @@ -140,10 +135,8 @@ const canon = (v) => .map((k) => [k, canon(v[k])]), ) : v; -// Record fields sort at every depth, so a state keeps its key when a field is added back. export const stateKey = (vars) => JSON.stringify(canon(vars)); -// The rule sentence of each definition: the \* comment lines right above NAME ==. export function ruleSentences(tla) { const out = new Map(); const lines = tla.split("\n"); @@ -159,8 +152,6 @@ export function ruleSentences(tla) { return out; } -// ---------- TLC -dump dot,actionlabels ---------- - function unescapeDot(s) { let out = ""; for (let i = 0; i < s.length; i++) { @@ -172,8 +163,6 @@ function unescapeDot(s) { return out; } -// Returns { nodes, edges, loops }. loops are steps that change no variable; they count as enabled -// actions but are not transitions. export function parseDot(text) { const nodes = new Map(); const edges = []; @@ -214,9 +203,6 @@ export function parseDot(text) { return { nodes, edges, loops }; } -// ---------- TLC error traces (one per violation; many with -continue) ---------- - -// A trace that follows an error other than a broken invariant has invariant null. export function parseTraces(text) { const traces = []; let invariant = null; @@ -254,8 +240,8 @@ export function parseTraces(text) { return traces; } -// Throws unless TLC finished and every error it printed is a broken invariant. With -continue, TLC -// prints "Model checking completed" even after broken invariants, and not when it stopped early. +// With -continue, TLC prints "Model checking completed" even after broken invariants, and not +// when it stopped early. export function checkTlcOutput(text, file) { const other = text .split("\n") @@ -274,8 +260,6 @@ export function checkTlcOutput(text, file) { ); } -// ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- - export function wrap(text, width = 26) { const words = String(text).split(/\s+/); const rows = []; @@ -290,8 +274,6 @@ export function wrap(text, width = 26) { return rows.join("
"); } -// The shared words stop before a word that opens a phrase, so each choice keeps its whole phrase, -// as in "you tap the Pay button or the Back button". const OPENS_A_PHRASE = new Set( "a an the your my our their its this that to of in on at for with from by and or".split(" "), ); @@ -318,19 +300,9 @@ const unwrap = (s) => // Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. export const quote = (s) => String(s).replaceAll('"', "#quot;"); -// The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; -// Marks a chart whose edge labels are written in full, which a chart comparison needs. export const FULL_LABELS = "%% labels in full"; -// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label,guard?}] } }, nevers }. -// Composite states flatten to "Parent.Child". A transition on a composite leaves from each state -// inside it that has no transition with the same label, and enters at the composite's [*] start. -// One top-level composite split by "--" makes one region per part. Regions nest only at the top. -// Words in [ ] in a label say when the step can happen. They are not part of the label, and nothing -// checks them. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition guards it -// on the states of other regions; its label must have the guard's words in [ ]. -// guard is { not, states: { region: [state] } }. export function parseMermaid(text) { const names = new Map(); const nevers = []; @@ -340,7 +312,6 @@ export function parseMermaid(text) { const stack = [root]; const part = () => stack[stack.length - 1].parts.at(-1); const display = (id) => names.get(id) ?? id; - // As in Mermaid, a state that only transitions name belongs to the part where it first appears. const implicit = new Map(); const declare = (id) => { const prev = implicit.get(id); @@ -426,7 +397,6 @@ export function parseMermaid(text) { } m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); if (m) { - // Mermaid shows every description line; the first one names the state. if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); if (implicit.get(m[1]) === null) continue; declare(m[1]); @@ -442,8 +412,6 @@ export function parseMermaid(text) { ); // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. - // A composite's full name prefixes its children ("Parent.Child"). An id used only in a - // transition is a plain state in the part where it first appears. function region(p) { const out = { states: new Set(), initial: new Set(), transitions: [] }; const info = new Map(); @@ -506,8 +474,6 @@ export function parseMermaid(text) { states(p, "", 0); for (const t of p.transitions) if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); - // The deepest source wins: a composite's transition leaves only from states inside it - // that have no deeper transition with the same label. transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); const handled = new Set(); let pending = []; @@ -532,12 +498,10 @@ export function parseMermaid(text) { if (t.label) pending.push(key); } } - for (const [id, { all }] of info) owners.set(id, { name: regionName, all }); - return out; + return [out, info]; } - const owners = new Map(); - let regionName = ""; + const owners = new Map(); const regions = {}; const top = root.parts[0]; const machine = top.children.filter((c) => c.name !== LEGEND_ID); @@ -557,12 +521,15 @@ export function parseMermaid(text) { : null; if (holder && holder.parts.length > 1) throw new Error(`${display(holder.name)} splits into regions below the top level`); - regionName = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; - regions[regionName] = region(holder ? holder.parts[0] : p); + const name = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; + const [r, ids] = region(holder ? holder.parts[0] : p); + regions[name] = r; + for (const [id, { all }] of ids) owners.set(id, { name, all }); } } else { - regionName = "Main"; - regions.Main = region(top); + const [r, ids] = region(top); + regions.Main = r; + for (const [id, { all }] of ids) owners.set(id, { name: "Main", all }); } for (const [name, r] of Object.entries(regions)) for (const t of r.transitions) { @@ -586,19 +553,12 @@ export function parseMermaid(text) { return { regions, nevers }; } -// Whether a guard lets a step happen while the regions are in the states that `at` maps them to. -export function guardHolds(guard, at) { +function guardHolds(guard, at) { if (!guard) return true; const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r])); return guard.not ? !hits.some(Boolean) : hits.every(Boolean); } -// The combined machine of a chart's regions, as chart-to-tla.mjs generates it. A labeled step can -// happen when at least one region can take it; every region that can take it moves, the others stay. -// A region can take a transition only while its guard holds in the state before the step. -// An unlabeled transition moves its own region only. A combined state is the region states joined -// by " | " in region order. Only states reachable from the start are kept, and steps that change -// nothing are dropped, as TLC's state graph drops them. export function productOf(regions) { const names = Object.keys(regions); const rs = Object.values(regions); @@ -661,9 +621,6 @@ export function productOf(regions) { return out; } -// Compares two charts as the machines their steps reach. Returns [title, differences] rows, only -// the rows that have differences. An edge label "a or b" counts as two labels. An unlabeled edge -// counts only when no labeled edge joins the same two states. export function compareCharts(expectedRegions, actualRegions) { const expected = productOf(expectedRegions); const actual = productOf(actualRegions); @@ -680,7 +637,6 @@ export function compareCharts(expectedRegions, actualRegions) { return out; }; const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); - // A region state that no combination of the agreed chart's own steps reaches can never be drawn. const unreached = []; const regionEntries = Object.entries(expectedRegions); if (regionEntries.length === 1) { @@ -717,8 +673,6 @@ export const STATUS_CLASSES = [ "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", ]; -// Fills for the mapping's groups that it gives no color: light, and not red, green, or gray, so a -// status border stays clear on each. export const GROUP_PALETTE = [ "#d6eaf8", "#fdebd0", @@ -735,14 +689,13 @@ const rgb = (hex) => { return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }; // WCAG relative luminance: 0 for black, 1 for white. -export function luminance(hex) { +function luminance(hex) { const [r, g, b] = rgb(hex).map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } -// Why a group color cannot fill a state, or null when it can. export function groupColorProblem(color) { if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) return 'is not "#rgb" or "#rrggbb"'; @@ -750,7 +703,6 @@ export function groupColorProblem(color) { return null; } -// A status border sits on top of the group fill. The path border shows only on states that work. const BORDERS = { Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"], Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"], diff --git a/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.cfg b/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.cfg new file mode 100644 index 000000000..68fd715a8 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +INVARIANT ChartsOneSpec +INVARIANT KeepsYourFiles +INVARIANT NeverLosesChart +INVARIANT ExitTwoLeavesFolder +INVARIANT OverlapNeverRefuses diff --git a/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.map.mjs b/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.map.mjs new file mode 100644 index 000000000..9618338f6 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.map.mjs @@ -0,0 +1,106 @@ +const QUIET = ["idle", "exited", "stopped", "orphanTlc"]; +const going = (v) => v.pc.filter((p) => !QUIET.includes(p)).length; +const changed = (before, after) => + after.pc.findIndex( + (p, i) => p !== before.pc[i] || after.aside[i].exists !== before.aside[i].exists, + ) + 1; + +export const readerActions = ["Start", "Edit", "Note", "Stop", "KillNode"]; + +function context(v) { + if (v.pc.every((p) => p === "idle")) return "Before any run"; + if (going(v) === 2) return "Two runs going"; + return going(v) === 1 ? "One run going" : "Done"; +} + +const SPECS = { 1: "the spec", 2: "the edited spec", "1,2": "both specs" }; + +function contents(v) { + const kept = v.aside.filter((a) => a.exists); + const aside = kept.some((a) => a.notes) + ? ", your notes moved aside" + : kept.some((a) => a.by) + ? ", the old chart moved aside" + : ""; + const parts = [ + v.out.by && `chart of ${SPECS[v.out.read.join()]}`, + v.out.notes && "your notes", + ].filter(Boolean); + if (!v.out.exists) return `no folder${aside}`; + return `${parts.join(" and ") || "empty folder"}${aside}`; +} + +export function state(v) { + return `${context(v)}: ${contents(v)}`; +} + +export function group(v) { + if (v.pc.every((p) => p === "idle")) return "Not started"; + return going(v) ? "Running" : "Finished"; +} + +export function step(action, before, after) { + const r = changed(before, after); + switch (action) { + case "Start": { + const where = going(before) ? " in a second shell" : ""; + const how = after.inputOk[r - 1] ? "" : " with a typo"; + return `you start run ${r}${where}${how}`; + } + case "Refuse": + return before.inputOk[r - 1] + ? `run ${r} says the folder has a file that it did not write` + : `run ${r} says the input is wrong`; + case "Tlc": + return `run ${r}'s TLC reads the spec`; + case "Draw": + return `run ${r} draws the chart in its own folder`; + case "Stale": + return `run ${r} says the spec changed while it read it`; + case "Foreign": + return `run ${r} says the folder has a file that it did not write`; + case "Swap": + case "Checked": + return ""; + case "MoveIn": + return `run ${r} moves its folder into place`; + case "MoveAside": + return `run ${r} moves the old folder aside`; + case "Restore": + return `run ${r} puts the folder with your notes back and stops`; + case "Supersede": + return `run ${r} finds another run's folder in place and deletes the old folder`; + case "KeepAside": + return `run ${r} keeps the folder with your notes aside and says where`; + case "RenameFails": + if (after.aside[r - 1].exists) + return `run ${r} cannot rename a folder and says where the old folder is`; + return after.out.exists && !before.out.exists + ? `run ${r} cannot rename a folder, puts the old folder back, and stops` + : `run ${r} cannot rename a folder and stops`; + case "Clean": + return `run ${r} deletes the old folder and finishes`; + case "Edit": + return "you edit the spec"; + case "Note": + return "you save notes in the folder"; + case "Stop": + return `you stop run ${r} with Ctrl-C`; + case "KillNode": + return `you kill run ${r}'s Node, and its TLC keeps running`; + case "OrphanEnd": + return `run ${r}'s orphaned TLC finishes`; + default: + return action; + } +} + +export function cause(v, facts, invariant) { + return { + ChartsOneSpec: "the run read the spec before and after an edit, and still wrote its chart", + KeepsYourFiles: "a run deleted a folder that had your notes in it", + NeverLosesChart: "a run deleted the last chart before another chart was in place", + ExitTwoLeavesFolder: "a run wrote the folder and then said it could not finish", + OverlapNeverRefuses: "a run stopped only because another run overlapped it", + }[invariant]; +} diff --git a/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.tla b/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.tla new file mode 100644 index 000000000..7765babcb --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/model/OutputFolder.tla @@ -0,0 +1,176 @@ +---- MODULE OutputFolder ---- +EXTENDS Naturals + +Runs == {1, 2} +NoRun == 0 +NoFolder == [exists |-> FALSE, by |-> NoRun, read |-> {}, notes |-> FALSE] + +VARIABLES pc, inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode + +vars == <> + +Init == + /\ pc = [r \in Runs |-> "idle"] + /\ inputOk = [r \in Runs |-> TRUE] + /\ snap = [r \in Runs |-> 0] + /\ read = [r \in Runs |-> {}] + /\ out = NoFolder + /\ aside = [r \in Runs |-> NoFolder] + /\ spec = 1 + /\ noted = FALSE + /\ wrote = [r \in Runs |-> FALSE] + /\ broke = [r \in Runs |-> FALSE] + /\ exitCode = [r \in Runs |-> "none"] + +Set(f, r, v) == [f EXCEPT ![r] = v] +Going(r) == pc[r] \notin {"idle", "exited", "stopped", "orphanTlc"} +Go(r, to) == pc' = Set(pc, r, to) +Exit(r, code) == pc' = Set(pc, r, "exited") /\ exitCode' = Set(exitCode, r, code) + +Start(r) == + /\ pc[r] = "idle" + /\ r = 2 => pc[1] # "idle" + /\ \E ok \in BOOLEAN : inputOk' = Set(inputOk, r, ok) + /\ snap' = Set(snap, r, spec) + /\ Go(r, "check") + /\ UNCHANGED <> + +Refuse(r) == + /\ pc[r] = "check" + /\ ~inputOk[r] \/ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Tlc(r) == + /\ \/ pc[r] = "check" /\ inputOk[r] /\ ~out.notes + \/ pc[r] = "tlc" + /\ read' = Set(read, r, read[r] \cup {spec}) + /\ Go(r, IF pc[r] = "check" THEN "tlc" ELSE "draw") + /\ UNCHANGED <> + +Draw(r) == + /\ pc[r] = "draw" + /\ Go(r, "publish") + /\ UNCHANGED <> + +Stale(r) == + /\ pc[r] = "publish" /\ spec # snap[r] + /\ Exit(r, "2") + /\ UNCHANGED <> + +Foreign(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Swap(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ ~out.notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +MoveIn(r) == + /\ pc[r] = "swap" /\ ~out.exists + /\ out' = [exists |-> TRUE, by |-> r, read |-> read[r], notes |-> FALSE] + /\ wrote' = Set(wrote, r, TRUE) + /\ Go(r, "clean") + /\ UNCHANGED <> + +MoveAside(r) == + /\ pc[r] = "swap" /\ out.exists /\ ~aside[r].exists + /\ aside' = Set(aside, r, out) + /\ out' = NoFolder + /\ Go(r, "aside") + /\ UNCHANGED <> + +Checked(r) == + /\ pc[r] = "aside" /\ ~aside[r].notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +Supersede(r) == + /\ pc[r] = "swap" /\ out.exists /\ aside[r].exists /\ ~out.notes + /\ aside' = Set(aside, r, NoFolder) + /\ UNCHANGED <> + +Restore(r) == + /\ pc[r] = "aside" /\ aside[r].notes /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "2") + /\ UNCHANGED <> + +KeepAside(r) == + /\ \/ pc[r] = "aside" /\ aside[r].notes /\ out.exists + \/ pc[r] = "swap" /\ aside[r].exists /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +RenameFails(r) == + /\ pc[r] \in {"swap", "aside"} + /\ \/ /\ aside[r].exists /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + \/ UNCHANGED <> + /\ broke' = Set(broke, r, TRUE) + /\ Exit(r, "2") + /\ UNCHANGED <> + +Clean(r) == + /\ pc[r] = "clean" + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "0") + /\ UNCHANGED <> + +Stop(r) == + /\ Going(r) + /\ Go(r, "stopped") + /\ UNCHANGED <> + +KillNode(r) == + /\ pc[r] = "tlc" + /\ Go(r, "orphanTlc") + /\ UNCHANGED <> + +OrphanEnd(r) == + /\ pc[r] = "orphanTlc" + /\ Go(r, "stopped") + /\ UNCHANGED <> + +Edit == + /\ spec = 1 + /\ spec' = 2 + /\ UNCHANGED <> + +Note == + /\ ~noted + /\ noted' = TRUE + /\ out' = [out EXCEPT !.exists = TRUE, !.notes = TRUE] + /\ UNCHANGED <> + +Next == + \/ Edit \/ Note + \/ \E r \in Runs : + Start(r) \/ Refuse(r) \/ Tlc(r) \/ Draw(r) \/ Stale(r) \/ Foreign(r) \/ Swap(r) + \/ MoveIn(r) \/ MoveAside(r) \/ Checked(r) \/ Supersede(r) \/ Restore(r) + \/ KeepAside(r) \/ RenameFails(r) \/ Clean(r) \/ Stop(r) \/ KillNode(r) \/ OrphanEnd(r) + +Spec == Init /\ [][Next]_vars + +\* The folder never holds a chart drawn from two versions of the spec. +ChartsOneSpec == out.read \in {{}, {1}, {2}} + +\* A file that you put in the folder is never deleted. +KeepsYourFiles == noted => out.notes \/ \E r \in Runs : aside[r].notes + +\* Once a run writes a chart, the folder or a folder that a run moved aside always holds one. +NeverLosesChart == + (\E r \in Runs : wrote[r]) => out.by # NoRun \/ \E r \in Runs : aside[r].by # NoRun + +\* A run that exits 2 did not write the folder. +ExitTwoLeavesFolder == \A r \in Runs : exitCode[r] = "2" => ~wrote[r] + +\* A run exits 2 only for a wrong input, an edited spec, a file that it did not write, or a failed +\* rename, never because another run overlaps it. +OverlapNeverRefuses == + \A r \in Runs : exitCode[r] = "2" => ~inputOk[r] \/ spec # snap[r] \/ noted \/ broke[r] +==== diff --git a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs index ada61542f..6e2ebfccd 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/statechart.mjs @@ -63,11 +63,15 @@ const FROM_CHART = `from-chart CHART.mmd const COMMON = `Both commands --expect-broken RULE - A rule that is known to break. Repeat it. The command then exits 0 when - exactly these rules break, and the --fixed or --against model breaks no other - rule. + A rule that is known to break. Repeat it, or list rules with commas, as in + A,B. The command then exits 0 when exactly these rules break, and the --fixed + or --against model breaks no other rule. --title TEXT The chart title. Each bug chart adds ", bug N". - --out DIR The output folder. Default: NAME-statechart next to the input. + --out DIR The output folder. Default: NAME-statechart next to the input. A run writes its + files in a temporary folder, and then replaces the whole output folder with + it. When runs overlap, the output folder has the files of the run that + finished last. The command does not use an output folder that has files that + it did not write. --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. -h, --help Print this help, and exit 0. @@ -89,7 +93,7 @@ Output tlc/regions-proof.mmd, every state and step of TLC's graph in one region. compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the proof. - *.png Each chart as a PNG, with --png. + *.png Each chart as a PNG, with --png. A run without --png deletes them. Reading a chart from-spec draws the overview as two parallel regions when every state reads "context: progress": @@ -140,9 +144,13 @@ Exit codes 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not CHART.mmd. The output says which. 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it - cannot read, no TLC, a new mapping to fill in, or a chart that --png cannot render. When a - chart does not render, the command keeps the files it wrote and exits 2, whatever the rules - give.`; + cannot read, no TLC, a helper script that cannot start, an output folder with files that the + command did not write, an input file that changed during the run, a new mapping to fill in, + or a chart that --png cannot render. When a chart does not render, the command keeps the + files it wrote and exits 2, whatever the rules give. Otherwise, exit 2 does not change the + output folder, unless the command moved it aside and cannot put it back. Then the message + names the folder's new path. When TLC stops on an error, the message names TLC's output, in a + temporary folder that the command keeps.`; const HELP = { "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} @@ -163,6 +171,7 @@ const more = (cmd) => `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; class CannotFinish extends Error {} +class TlcStopped extends CannotFinish {} const COMMANDS = { "from-spec": { @@ -185,7 +194,7 @@ function parseArgs([command, ...rest]) { else if (COMMANDS[command].flags.includes(a)) { if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`); const name = a.slice(2); - if (flags[name] instanceof Set) flags[name].add(rest[++i]); + if (flags[name] instanceof Set) for (const v of rest[++i].split(",")) flags[name].add(v); else flags[name] = rest[++i]; } else if (!a.startsWith("-") && input === undefined) input = a; else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`); @@ -209,6 +218,7 @@ function script(name, scriptArgs, about, { okExits = [0] } = {}) { const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { encoding: "utf8", }); + if (r.error) throw new CannotFinish(`cannot run ${name}: ${r.error.message}`); if (okExits.includes(r.status)) return r; const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); @@ -237,10 +247,15 @@ function splitRules(cfg) { function tlc(tools, spec, cfg, base, dump) { fs.writeFileSync(`${base}.cfg`, cfg); const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); + // TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names, + // so runs that share that folder can read a module while another run rewrites it. + const javaTmp = path.join(metadir, "tmp"); + fs.mkdirSync(javaTmp); // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. const r = spawnSync( tools.java, [ + `-Djava.io.tmpdir=${javaTmp}`, "-cp", tools.jar, "tlc2.TLC", @@ -259,9 +274,9 @@ function tlc(tools, spec, cfg, base, dump) { if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); fs.writeFileSync(`${base}.out`, r.stdout); try { - checkTlcOutput(r.stdout, rel(`${base}.out`)); + checkTlcOutput(r.stdout, `${base}.out`); } catch (e) { - throw new CannotFinish(e.message); + throw new TlcStopped(e.message); } return r.stdout; } @@ -302,9 +317,9 @@ function readInput(spec, expected) { return model; } -function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, out, expected) { +function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, work, expected) { const name = path.basename(spec, ".tla"); - const tlcDir = path.join(out, "tlc"); + const tlcDir = path.join(work, "tlc"); const dot = path.join(tlcDir, `${name}.dot`); tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); warnUnnamedSteps(spec, cfgWithoutRules, dot); @@ -401,7 +416,8 @@ export function step(action, before, after) { `; }; -async function draw({ now, other, map, out, title, comparing }) { +async function draw({ now, other, map, work, out, title, comparing }) { + const shown = published(work, out); const mapping = await import(pathToFileURL(path.resolve(map)).href); const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; from.push(...now.traces.flatMap((t) => ["--traces", t.file])); @@ -413,17 +429,14 @@ async function draw({ now, other, map, out, title, comparing }) { if (comparing) from.push("--full-labels", "--flat"); else if (mapping.readerActions?.length) from.push("--cover"); const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []); - for (const f of fs.readdirSync(out)) - if (/^bug-\d+(-developer|-layered)?\.(mmd|png)$/.test(f)) fs.rmSync(path.join(out, f)); const files = { - overview: path.join(out, "chart.mmd"), - stories: path.join(out, "stories.md"), - summary: path.join(out, "summary.json"), + overview: path.join(work, "chart.mmd"), + stories: path.join(work, "stories.md"), + summary: path.join(work, "summary.json"), }; - const proof = path.join(out, "tlc", "regions-proof.mmd"); - const everyArrow = path.join(out, "tlc", "regions-all.mmd"); - for (const f of [proof, everyArrow]) fs.rmSync(f, { force: true }); + const proof = path.join(work, "tlc", "regions-proof.mmd"); + const everyArrow = path.join(work, "tlc", "regions-all.mmd"); script( "tlc-to-chart.mjs", [ @@ -446,7 +459,7 @@ async function draw({ now, other, map, out, title, comparing }) { for (const c of summary.classes) { const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; for (const view of views) { - const file = path.join(out, `bug-${c.n}${view}.mmd`); + const file = path.join(work, `bug-${c.n}${view}.mmd`); const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"]; script( "tlc-to-chart.mjs", @@ -465,7 +478,7 @@ async function draw({ now, other, map, out, title, comparing }) { const r = summary.regions; if (r?.drawn) console.log( - `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 ${rel(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${rel(everyArrow)} has all ${r.all} arrows.`, + `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.`, ); else if (r) console.log(`It has no parallel regions, because ${r.why}.`); const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; @@ -488,7 +501,7 @@ async function draw({ now, other, map, out, title, comparing }) { return { files, charts }; } -function render(charts) { +function render(charts, shown) { const pngs = []; for (const chart of charts) { const png = chart.replace(/\.mmd$/, ".png"); @@ -502,14 +515,15 @@ function render(charts) { if (r.status === 0) pngs.push(png); else console.error( - `could not render ${rel(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, + `could not render ${shown(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, ); } return pngs; } -function finish(out, { files, charts }, ok, png) { - const pngs = png ? render(charts) : []; +function finish({ work, out, inputs }, { files, charts }, ok, png) { + const pngs = png ? render(charts, published(work, out)) : []; + publish(work, out, inputs); const written = [...charts, ...pngs, files.stories, files.summary] .map((f) => path.basename(f)) .join(", "); @@ -518,12 +532,121 @@ function finish(out, { files, charts }, ok, png) { return ok ? 0 : 1; } +const published = (work, out) => (file) => rel(path.join(out, path.relative(work, file))); + +const OWN = + /^((chart|bug-\d+(-developer|-layered)?)\.(mmd|png)|stories\.md|summary\.json|tlc|\.DS_Store)$/; + +function foreign(dir) { + try { + return fs + .readdirSync(dir) + .filter((f) => !OWN.test(f)) + .sort(); + } catch (e) { + if (e.code === "ENOENT") return []; + throw new CannotFinish(`cannot use ${rel(dir)} as the output folder: ${e.message}`); + } +} + +function refuseForeign(out, files = foreign(out)) { + const [first, ...others] = files; + if (first) + throw new CannotFinish( + `${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.`, + ); +} + function outDir(flagged, input, name) { const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`); - fs.mkdirSync(path.join(out, "tlc"), { recursive: true }); + refuseForeign(out); return out; } +const fingerprint = (file) => { + try { + const { ino, ctimeNs } = fs.statSync(file, { bigint: true }); + return `${ino} ${ctimeNs} ${fs.readFileSync(file, "base64")}`; + } catch { + return null; + } +}; + +function snapshot(files, inputs = new Map()) { + for (const f of files) if (f && !inputs.has(f)) inputs.set(f, fingerprint(f)); + return inputs; +} + +async function inWorkFolder(body) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")); + fs.mkdirSync(path.join(work, "tlc")); + let keep = false; + try { + return await body(work); + } catch (e) { + keep = e instanceof TlcStopped; + throw e; + } finally { + if (!keep) fs.rmSync(work, { recursive: true, force: true }); + } +} + +const moved = (from, to, ...missed) => { + try { + fs.renameSync(from, to); + return true; + } catch (e) { + if (missed.includes(e.code)) return false; + throw e; + } +}; + +function publish(work, out, inputs) { + const parent = path.dirname(path.resolve(out)); + fs.mkdirSync(parent, { recursive: true }); + const next = fs.mkdtempSync(path.join(parent, `.${path.basename(out)}-`)); + const old = `${next}-old`; + let aside = false; + try { + fs.cpSync(work, next, { recursive: true }); + const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0]; + if (changed) + throw new CannotFinish( + `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`, + ); + refuseForeign(out); + while (!moved(next, out, "ENOTEMPTY", "EEXIST")) { + if (aside) { + refuseForeign(out); + fs.rmSync(old, { recursive: true, force: true }); + aside = false; + } + aside = moved(out, old, "ENOENT"); + if (aside) refuseForeign(out, foreign(old)); + } + } catch (e) { + const back = !aside || putBack(old, out); + fs.rmSync(next, { recursive: true, force: true }); + const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`; + if (!back) + throw new CannotFinish( + `${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`, + ); + throw e instanceof CannotFinish + ? e + : new CannotFinish(`${why} The run left ${rel(out)} as it was.`); + } + if (aside) fs.rmSync(old, { recursive: true, force: true }); +} + +function putBack(old, out) { + try { + return moved(old, out, "ENOTEMPTY", "EEXIST"); + } catch { + return false; + } +} + function secondModel(input, { fixed, against, label }) { if (fixed && against) throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); @@ -540,28 +663,35 @@ function secondModel(input, { fixed, against, label }) { throw new CannotFinish( `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, ); - return { role, spec, label }; + const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); + return { role, label, model: readModel(spec, cfg) }; } async function fromSpec(input, { "expect-broken": expected, ...flags }) { + const name = path.basename(input, ".tla"); + const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const secondSpec = flags.fixed ?? flags.against; + const inputs = snapshot([input, cfgOf(input), map, secondSpec, secondSpec && cfgOf(secondSpec)]); const second = secondModel(input, flags); const tools = findTools(); - const name = path.basename(input, ".tla"); + const model = readInput(input, expected); const out = outDir(flags.out, input, name); - const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); - const now = check(tools, readInput(input, expected), out, expected); - if (!fs.existsSync(map)) { - fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); - throw new CannotFinish(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); - } - let other = null; - if (second) { - const { role, spec, label } = second; - const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); - other = { role, label, ...check(tools, readModel(spec, cfg), out, expected) }; - } - const drawn = await draw({ now, other, map, out, title: flags.title }); - return finish(out, drawn, rulesAsExpected(now, other, expected), flags.png); + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); + throw new CannotFinish( + `\nWrote ${rel(map)}. Fill in its labels, then run this command again.`, + ); + } + const other = second && { + role: second.role, + label: second.label, + ...check(tools, second.model, work, expected), + }; + const drawn = await draw({ now, other, map, work, out, title: flags.title }); + return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), flags.png); + }); } async function fromChart(input, { "expect-broken": expected, ...flags }) { @@ -573,36 +703,36 @@ async function fromChart(input, { "expect-broken": expected, ...flags }) { if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`); const spec = path.join(path.dirname(input), `${name}.tla`); + const map = spec.replace(/\.tla$/, ".map.mjs"); + const inputs = snapshot([input]); if (fs.existsSync(spec)) { console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); } else { script("chart-to-tla.mjs", [input, "--module", name], input); console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); } + snapshot([spec, cfgOf(spec), map], inputs); const tools = findTools(); + const model = readInput(spec, expected); const out = outDir(flags.out, input, name); - const now = check(tools, readInput(spec, expected), out, expected); - const drawn = await draw({ - now, - map: spec.replace(/\.tla$/, ".map.mjs"), - out, - title: flags.title, - comparing: true, - }); - const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { - okExits: [0, 1], - }); - if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); - else - console.log( - `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true }); + const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { + okExits: [0, 1], + }); + if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); + else + console.log( + `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + ); + return finish( + { work, out, inputs }, + drawn, + rulesAsExpected(now, null, expected) && compared.status === 0, + flags.png, ); - return finish( - out, - drawn, - rulesAsExpected(now, null, expected) && compared.status === 0, - flags.png, - ); + }); } try { diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 79618ba78..a90c7e4a3 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -1,9 +1,18 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { checkTlcOutput, @@ -741,6 +750,71 @@ test("statechart.mjs writes the spec next to the chart, keeps it on the next run ); }); +test("statechart.mjs writes no output folder when it refuses the input", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT Paid\n"); + writeFileSync(join(dir, "cart.mmd"), "stateDiagram-v2\n [*] --> Cart\n Cart --> Paid : pay\n"); + for (const args of [ + ["from-spec", "Pya.tla"], + ["from-spec", "Pay.tla", "--expect-broken", "Unpaid"], + ["from-spec", "Pay.tla", "--fixed", "PayFixd.tla"], + ["from-chart", "cart.mmd", "--expect-broken", "Unpaid"], + ]) { + const r = node("statechart.mjs", args, dir, { TLA2TOOLS: join(dir, "tla2tools.jar") }); + assert.equal(r.status, 2, r.stderr); + } + assert.deepEqual( + readdirSync(dir).filter((f) => f.endsWith("-statechart")), + [], + ); +}); + +test("statechart.mjs does not use an output folder that has files that it did not write", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + const env = { TLA2TOOLS: join(dir, "tla2tools.jar") }; + const cwd = node("statechart.mjs", ["from-spec", "Pay.tla", "--out", "."], dir, env); + assert.equal(cwd.status, 2); + assert.equal( + cwd.stderr, + ". has 3 files that this command did not write, such as Pay.cfg. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + const out = join(dir, "Pay-statechart"); + mkdirSync(join(out, "tlc"), { recursive: true }); + writeFileSync(join(out, "chart.mmd"), "kept\n"); + writeFileSync(join(out, ".DS_Store"), ""); + writeFileSync(join(out, "notes.md"), "mine\n"); + const notes = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, env); + assert.equal(notes.status, 2); + assert.equal( + notes.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(dir).sort(), [ + "Pay-statechart", + "Pay.cfg", + "Pay.tla", + "tla2tools.jar", + ]); + assert.deepEqual(readdirSync(out).sort(), [".DS_Store", "chart.mmd", "notes.md", "tlc"]); + assert.equal(readFileSync(join(out, "chart.mmd"), "utf8"), "kept\n"); +}); + +test("statechart.mjs exits 2 and names a helper script that cannot start", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + writeFileSync(join(dir, "no-node.cjs"), 'process.execPath = "/nonexistent/node";\n'); + const r = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, { + NODE_OPTIONS: `--require ${join(dir, "no-node.cjs")}`, + }); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /chart-to-tla\.mjs/); +}); + test("statechart.mjs names the chart it refuses and the argument it does not know", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync( @@ -1255,14 +1329,7 @@ test( scaffold, /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, ); - assert.equal( - readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), - "SPECIFICATION Spec\n", - ); - assert.equal( - readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), - "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", - ); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); writeFileSync( join(dir, "Pay.map.mjs"), @@ -1305,6 +1372,14 @@ Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, "summary.json", "tlc", ]); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), + "SPECIFICATION Spec\n", + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", + ); }, ); @@ -1638,6 +1713,10 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. holds.stdout, /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/, ); + const listed = run("--expect-broken", "NoFalseSaved,TypeOK"); + assert.equal(listed.status, 1, listed.stderr); + assert.match(listed.stdout, /\nExpected to break but holds: TypeOK\.\n/); + assert.doesNotMatch(listed.stdout, /Broken but not expected/); writeFileSync( join(dir, "code.map.mjs"), @@ -2280,3 +2359,432 @@ Spec == \\* not [][Inc]_x assert.equal(named.status, 0, named.stderr); assert.equal(named.stderr, ""); }); + +const writeLamp = (dir) => { + writeFileSync( + join(dir, "Lamp.tla"), + "---- MODULE Lamp ----\nVARIABLE on\nInit == on = FALSE\nOn == on' = TRUE\nOff == on' = FALSE\nSpec == Init /\\ [][On \\/ Off]_on\n====\n", + ); + writeFileSync(join(dir, "Lamp.cfg"), "SPECIFICATION Spec\n"); + writeFileSync( + join(dir, "Lamp.map.mjs"), + 'export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "you flip the switch";\n', + ); + return join(dir, "Lamp-statechart"); +}; + +test("a rerun without --png removes the chart.png of an earlier run", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeLamp(dir); + mkdirSync(out); + writeFileSync(join(out, "chart.png"), ""); + const r = node("statechart.mjs", ["from-spec", "Lamp.tla"], dir); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + readdirSync(out).filter((f) => f.endsWith(".png")), + [], + ); +}); + +const writeSlowPay = (dir, ticks, paid = "Paid") => { + writeFileSync( + join(dir, "Pay.tla"), + `---- MODULE Pay ---- +EXTENDS Naturals +VARIABLES screen, charges, ticks +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ ticks = 0 +TapPay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED <> +Charge == screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED <> +Done == screen = "Paying" /\\ charges > 0 /\\ screen' = "${paid}" /\\ UNCHANGED <> +Tick == ticks < ${ticks} /\\ ticks' = ticks + 1 /\\ UNCHANGED <> +Spec == Init /\\ [][TapPay \\/ Charge \\/ Done \\/ Tick]_vars +ChargedAtMostTwice == charges <= 2 +==== +`, + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT ChargedAtMostTwice\n"); + writeFileSync( + join(dir, "Pay.map.mjs"), + 'export const state = (v) => v.screen;\nexport const step = (a) => ({ TapPay: "you tap Pay", Charge: "the charge goes through", Done: "the receipt shows" })[a] ?? "";\n', + ); + return join(dir, "Pay-statechart"); +}; +const startRun = (dir, { wrap = [], env = {}, args = ["from-spec", "Pay.tla"] } = {}) => { + const [command, ...rest] = [...wrap, process.execPath, join(scripts, "statechart.mjs"), ...args]; + const child = spawn(command, rest, { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "ignore", "pipe"], + detached: true, + }); + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += d; + }); + return { + child, + done: new Promise((done) => child.on("close", (status) => done({ status, stderr }))), + }; +}; +const until = async (ready) => { + while (!ready()) await sleep(20); +}; +const ownTmp = (dir, name) => { + const tmp = join(dir, name); + mkdirSync(tmp); + return tmp; +}; +const tlcStarted = (tmp) => readdirSync(tmp).some((f) => f.startsWith("tlc-")); +const staged = (dir) => readdirSync(dir).filter((f) => f.startsWith(".")); + +test( + "a run whose Node is killed mid-TLC leaves the output folder as it was, and a rerun charts its own spec", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 60000); + const tmp = ownTmp(dir, "tmp"); + const first = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(300); + first.child.kill("SIGTERM"); + await first.done; + assert.doesNotThrow(() => process.kill(-first.child.pid, 0), "TLC outlives its Node"); + assert.equal(existsSync(out), false); + + writeSlowPay(dir, 200, "Receipt"); + const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + process.kill(-first.child.pid, "SIGKILL"); + assert.equal(second.status, 0, second.stderr); + for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) { + const text = readFileSync(join(out, file), "utf8"); + assert.match(text, /Receipt/); + assert.doesNotMatch(text, /Paid/); + } + assert.deepEqual(staged(dir), []); + }, +); + +const changedMidRun = async ({ wrap = [], env = {} }) => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp-a"); + const first = startRun(dir, { wrap, env: { ...env, TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(500); + writeSlowPay(dir, 20000, "Receipt"); + const second = startRun(dir, { wrap, env: { ...env, TMPDIR: ownTmp(dir, "tmp-b") } }); + const [paid, receipt] = await Promise.all([first.done, second.done]); + assert.equal(receipt.status, 0, receipt.stderr); + assert.equal(paid.status, 2, paid.stderr); + assert.equal( + paid.stderr, + "Pay.tla changed while this run read it, so the run left Pay-statechart as it was. Run the command again.\n", + ); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /Receipt/); + assert.doesNotMatch(chart, /Paid/); + assert.deepEqual(staged(dir), []); +}; + +test( + "a run whose spec changes while it reads it leaves the output folder, and the run that read the new spec charts it", + needsTlc, + () => changedMidRun({}), +); + +const unshare = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"]; +const hidePidNamespace = `#define _GNU_SOURCE +#include +#include +#include +#include +ssize_t readlink(const char *path, char *buf, size_t size) { + static ssize_t (*real)(const char *, char *, size_t); + if (!real) real = dlsym(RTLD_NEXT, "readlink"); + if (path && strcmp(path, "/proc/self/ns/pid") == 0) { errno = EIO; return -1; } + return real(path, buf, size); +} +`; +const hidingPreload = (() => { + if (spawnSync(unshare[0], [...unshare.slice(1), "true"]).status !== 0) return null; + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "hide.c"), hidePidNamespace); + const so = join(dir, "hide.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "hide.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "runs in PID namespaces that hide which namespace they are in chart only their own spec", + { + skip: + needsTlc.skip || + (!hidingPreload && "cannot make a PID namespace, or cannot compile a readlink preload"), + }, + () => changedMidRun({ wrap: unshare, env: { LD_PRELOAD: hidingPreload } }), +); + +test( + "a file that is put in the output folder during a run stays, and the run exits 2", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp"); + const run = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + mkdirSync(out); + writeFileSync(join(out, "notes.md"), "mine\n"); + const r = await run.done; + assert.equal(r.status, 2); + assert.equal( + r.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(out), ["notes.md"]); + assert.deepEqual(staged(dir), []); + }, +); + +const failRenames = `#define _GNU_SOURCE +#include +#include +#include +#include +static size_t hits; +int rename(const char *from, const char *to) { + static int (*real)(const char *, const char *); + if (!real) real = dlsym(RTLD_NEXT, "rename"); + const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS"); + base = base ? base + 1 : to; + if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) { + errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO; + return -1; + } + return real(from, to); +} +`; +const failingRenames = (() => { + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "rename.c"), failRenames); + const so = join(dir, "rename.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "rename.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "a run whose rename fails after it moves the output folder aside puts the folder back, or says where it is", + { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") }, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeLamp(dir); + const args = ["from-spec", "Lamp.tla", "--out", "shared"]; + const shared = join(dir, "shared"); + const first = node("statechart.mjs", args, dir); + assert.equal(first.status, 0, first.stderr); + const chart = readFileSync(join(shared, "chart.mmd"), "utf8"); + const run = (fails) => + node("statechart.mjs", args, dir, { LD_PRELOAD: failingRenames, RENAME_FAILS: fails }); + + const back = run("NI"); + assert.equal(back.status, 2, back.stderr); + assert.match( + back.stderr, + /^cannot replace shared: EIO: .*\. The run left shared as it was\.\n$/, + ); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), []); + + const stuck = run("NII"); + assert.equal(stuck.status, 2, stuck.stderr); + const named = stuck.stderr.match( + /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\.\n$/, + ); + assert.ok(named, stuck.stderr); + const aside = named[1]; + assert.equal(existsSync(shared), false); + assert.equal(readFileSync(join(dir, aside, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), [aside]); + }, +); + +test( + "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeSlowPay(dir, 0); + const tmp = ownTmp(dir, "tmp"); + const log = join(dir, "java.log"); + const java = join(dir, "java"); + writeFileSync( + java, + `#!/bin/sh +for a; do case "$a" in -Djava.io.tmpdir=*) t="\${a#-Djava.io.tmpdir=}";; esac; done +"$REAL_JAVA" "$@" > "$LOG.$$"; s=$? +cat "$LOG.$$" +echo "$t: $(grep 'Naturals.tla' "$LOG.$$")" >> "$LOG" +rm "$LOG.$$" +exit $s +`, + { mode: 0o755 }, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { + JAVA: java, + TLA2TOOLS: tools.jar, + REAL_JAVA: tools.java, + LOG: log, + TMPDIR: tmp, + }); + assert.equal(r.status, 0, r.stderr); + const runs = readFileSync(log, "utf8").trim().split("\n"); + assert.ok(runs.length > 0); + const folders = new Set(); + for (const line of runs) { + const [folder, parsed] = line.split(": "); + assert.ok(folder.startsWith(join(tmp, "tlc-")), line); + assert.equal(parsed, `Parsing file ${join(folder, "Naturals.tla")}`); + folders.add(folder); + } + assert.equal(folders.size, runs.length); + }, +); + +test( + "a run that TLC stops on an error keeps TLC's output and writes no output folder", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + "---- MODULE Pay ----\nVARIABLE x\nASSUME FALSE\nInit == x = 0\nSpec == Init /\\ [][x' = x]_x\n====\n", + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Pay.map.mjs"), 'export const state = () => "Cart";\n'); + const tmp = ownTmp(dir, "tmp"); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { TMPDIR: tmp }); + assert.equal(r.status, 2); + const [, file] = r.stderr.match( + /^(\S+\/tlc\/Pay\.out): TLC stopped on an error that is not a broken rule: Assumption .* is false\.\n$/, + ); + assert.ok(file.startsWith(tmp), file); + assert.match(readFileSync(file, "utf8"), /Assumption .* is false/); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); + }, +); + +test( + "runs of two specs that overlap on one output folder all finish, and the folder always holds one run's files", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 400); + writeLamp(dir); + const pay = ["from-spec", "Pay.tla", "--out", "shared"]; + const lamp = ["from-spec", "Lamp.tla", "--out", "shared"]; + const alone = {}; + for (const args of [pay, lamp]) { + const solo = join(dir, `${args[1]}-alone`); + const r = node("statechart.mjs", [...args.slice(0, 2), "--out", solo], dir); + assert.equal(r.status, 0, r.stderr); + alone[args[1]] = { + top: readdirSync(solo).sort().join(), + tlc: readdirSync(join(solo, "tlc")).sort().join(), + chart: readFileSync(join(solo, "chart.mmd"), "utf8"), + summary: readFileSync(join(solo, "summary.json"), "utf8"), + }; + } + const shared = join(dir, "shared"); + const seen = new Set(); + let watching = true; + const watch = (async () => { + while (watching) { + try { + seen.add(readdirSync(join(shared, "tlc")).sort().join()); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + await sleep(5); + } + })(); + const runs = []; + for (let i = 0; i < 6; i++) { + runs.push(startRun(dir, { args: i % 2 ? lamp : pay }).done); + await sleep(150); + } + const finished = await Promise.all(runs); + watching = false; + await watch; + for (const r of finished) assert.equal(r.status, 0, r.stderr); + const runsOf = new Map(Object.entries(alone).map(([spec, a]) => [a.tlc, spec])); + for (const listing of seen) assert.ok(runsOf.has(listing), `tlc/ had ${listing}`); + const last = alone[runsOf.get(readdirSync(join(shared, "tlc")).sort().join())]; + assert.equal(readdirSync(shared).sort().join(), last.top); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), last.chart); + assert.equal(readFileSync(join(shared, "summary.json"), "utf8"), last.summary); + assert.equal(existsSync(out), false); + assert.deepEqual(staged(dir), []); + }, +); + +test( + "the output folder model keeps every rule, and breaks one with a shared TLC folder, without each guard, or when a failed rename deletes the folder moved aside", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const model = (ext) => readFileSync(join(scripts, "model", `OutputFolder${ext}`), "utf8"); + const r = node( + "statechart.mjs", + ["from-spec", join(scripts, "model", "OutputFolder.tla"), "--out", join(dir, "out")], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nNo rule is broken\.\n/); + for (const [i, [rule, guard, without, story]] of [ + [ + "ChartsOneSpec", + "/\\ read' = Set(read, r, read[r] \\cup {spec})", + "/\\ read' = [q \\in Runs |-> read[q] \\cup {spec}]", + /You start run 2 in a second shell\..*\n.*Run 2 moves its folder into place\. Now: Two runs going: chart of both specs/, + ], + [ + "ChartsOneSpec", + '/\\ pc[r] = "publish" /\\ spec = snap[r] /\\ ~out.notes', + '/\\ pc[r] = "publish" /\\ ~out.notes', + /You edit the spec\..*\n.*Run 1 moves its folder into place\. Now: One run going: chart of both specs/, + ], + [ + "KeepsYourFiles", + '/\\ pc[r] = "aside" /\\ ~aside[r].notes\n', + '/\\ pc[r] = "aside"\n', + /Run 1 deletes the old folder and finishes\. Now: Done: chart of the spec\n/, + ], + [ + "NeverLosesChart", + " /\\ \\/ /\\ aside[r].exists /\\ ~out.exists\n /\\ out' = aside[r]\n /\\ aside' = Set(aside, r, NoFolder)\n \\/ UNCHANGED <>\n", + " /\\ out' = out\n /\\ aside' = Set(aside, r, IF aside[r].notes THEN aside[r] ELSE NoFolder)\n", + /Run 2 moves the old folder aside\. Now: .*, the old chart moved aside\n.*Run 2 cannot rename a folder and stops\. Now: One run going: no folder\n/, + ], + [ + "NeverLosesChart", + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists /\\ ~out.notes', + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists', + /You save notes in the folder\..*\n.*Run 2 finds another run's folder in place and deletes the old folder\. Now: Two runs going: your notes\n/, + ], + ].entries()) { + const mutant = join(dir, `${i}-${rule}`); + mkdirSync(mutant); + assert.ok(model(".tla").includes(guard), guard); + writeFileSync(join(mutant, "OutputFolder.tla"), model(".tla").replace(guard, without)); + for (const ext of [".cfg", ".map.mjs"]) + writeFileSync(join(mutant, `OutputFolder${ext}`), model(ext)); + const m = node( + "statechart.mjs", + ["from-spec", "OutputFolder.tla", "--expect-broken", rule], + mutant, + ); + assert.equal(m.status, 0, m.stdout + m.stderr); + assert.match(m.stdout, story); + } + }, +); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs index a933666d0..ccb68fd7b 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/tlc-to-chart.mjs @@ -228,6 +228,7 @@ if (typeof mapping.state !== "function") if (args.view !== "customer" && typeof mapping.detail !== "function") throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); const SEP = "\u0000"; +const edgeId = (from, to) => `${from}${SEP}${SEP}${to}`; const rules = new Map(); for (const f of args.specs) @@ -263,7 +264,6 @@ function stepGuard(action, before, after) { .replace(/[[\]]/g, "") .trim(); } -// A label shows guard words only when every model step behind it has some. function guarded(guards, label) { const words = guards?.get(label); return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label; @@ -273,9 +273,6 @@ const addGuard = (guards, label, words) => { guards.get(label).add(words); }; -// ---------- projection between two models ---------- -// Paths of record fields that a has and b does not. It looks inside records only: sets and -// sequences can be empty in a start state, so they show no fields. function lacking(a, b, prefix = []) { return Object.keys(a).flatMap((k) => !(k in b) @@ -305,8 +302,6 @@ function fill(vars, paths, from) { return out; } -// One model: its graph, the facts of each state, and its reader keys. -// lookup turns a state as the chart sees it back into the model's own state. function modelOf(graph, lookup = (vars) => vars) { const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); @@ -335,7 +330,7 @@ function quotient(m) { const from = keys.get(e.from); const to = keys.get(e.to); if (from === to) continue; - const id = `${from}${SEP}${SEP}${to}`; + const id = edgeId(from, to); if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() }); const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars]; const label = stepLabel(e.action, before, after); @@ -343,7 +338,6 @@ function quotient(m) { edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); } - // Most common wording first. for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); const settled = new Set( @@ -359,24 +353,21 @@ const mainInit = [...graph.nodes.values()].find((n) => n.init).vars; const secondDot = args.fixedDot ?? args.againstDot; const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null; -let lacks = []; -let extra = []; -let second = null; -let secondQ = null; -if (secondGraph) { - const secondInit = [...secondGraph.nodes.values()].find((n) => n.init).vars; - lacks = lacking(mainInit, secondInit); - extra = lacking(secondInit, mainInit); - for (const n of secondGraph.nodes.values()) { - n.raw = n.vars; - n.vars = fill(n.raw, lacks, mainInit); - } - second = modelOf(secondGraph, (vars) => omit(vars, lacks)); - secondQ = quotient(second); -} +const secondInit = secondGraph && [...secondGraph.nodes.values()].find((n) => n.init).vars; +const lacks = secondGraph ? lacking(mainInit, secondInit) : []; +const extra = secondGraph ? lacking(secondInit, mainInit) : []; +const secondFilled = secondGraph && { + ...secondGraph, + nodes: new Map( + [...secondGraph.nodes].map(([id, n]) => [ + id, + { ...n, raw: n.vars, vars: fill(n.vars, lacks, mainInit) }, + ]), + ), +}; +const second = secondFilled && modelOf(secondFilled, (vars) => omit(vars, lacks)); +const secondQ = second && quotient(second); -// Reader path of one trace: drop system steps that keep the reader in the same state. -// Reader actions stay even then, because the reader did them. function readerPath(trace, key, labelOf = stepLabel) { const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; for (let i = 1; i < trace.steps.length; i++) { @@ -392,24 +383,19 @@ function readerPath(trace, key, labelOf = stepLabel) { } const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer"); -// Replay a bug on the second model, step by step. The reader sees the screens and step words of its -// reader path. The reader does not see a step that keeps the screen and that they do not take, so -// the second model must keep the screen at that step too, or be unable to take it. Returns the -// first step that the second model shows the reader differently, or, when it shows them all the -// same way, whether a state on the way breaks the same rule there. function replaySecond(trace) { if (!second) return null; const shared = (vars) => stateKey(omit(vars, lacks)); const sharedOf = (n) => stateKey(omit(n.raw, extra)); const out = new Map(); - for (const e of [...secondGraph.edges, ...secondGraph.loops]) { + for (const e of [...second.graph.edges, ...second.graph.loops]) { if (!out.has(e.from)) out.set(e.from, []); out.get(e.from).push(e); } - const actions = new Set([...secondGraph.edges, ...secondGraph.loops].map((e) => e.action)); - const nodeOf = (id) => secondGraph.nodes.get(id); + const actions = new Set([...second.graph.edges, ...second.graph.loops].map((e) => e.action)); + const nodeOf = (id) => second.graph.nodes.get(id); const screen = new Map( - [...secondGraph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), + [...second.graph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), ); const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action; const settle = (ids) => { @@ -422,7 +408,7 @@ function replaySecond(trace) { const seen = readerPath(trace, main.customer, readerLabel); const shownAt = new Map(seen.map((p, j) => [p.modelStep, j])); let here = settle( - [...secondGraph.nodes.values()] + [...second.graph.nodes.values()] .filter((n) => n.init && screen.get(n.id) === seen[0].key) .map((n) => n.id), ); @@ -491,7 +477,6 @@ function readTraces(files, paths = []) { }); } // With -continue, TLC reports every bad state, including states after the first bad one. -// Cut each trace at its first bad state so one bug counts once. function firstBad(raw) { const badStates = new Map(); for (const t of raw) { @@ -515,8 +500,6 @@ const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; const rawSecondTraces = readTraces(secondTraceFiles, lacks); const traces = firstBad(rawTraces); const secondTraces = firstBad(rawSecondTraces); -// Classes and their shortest traces come from the customer states in every view, -// so class N is the same bug and the same trace in the customer and developer charts. function group(cuts, m) { const byId = new Map(); for (const trace of cuts) { @@ -546,38 +529,44 @@ function group(cuts, m) { } return byId; } -// Fewest reader steps first, then fewest model steps, then rule name, then cause (or end state). const byOrder = (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); -const classesById = group(traces, main); -const classes = [...classesById.values()].sort(byOrder); -for (const c of classes) { - c.path = readerPath(c.trace, main.key); - c.end = c.path[c.path.length - 1].key; - c.second = replaySecond(c.trace); +const inSecond = (l) => `${within} ${L}, ${l}`; +const modelEdges = new Map(now.edges); +if (secondQ) + for (const [id, e] of secondQ.edges) + if (!modelEdges.has(id)) + modelEdges.set(id, { + ...e, + labels: new Set([...e.labels].map(inSecond)), + guards: new Map([...e.guards].map(([l, words]) => [inSecond(l), words])), + secondOnly: true, + }); +function divergence(s) { + if (s?.key === undefined) return s; + const plain = edgeId(s.from, s.key); + const text = inSecond(`${s.label}${s.disabled ? " cannot happen" : ""}`); + const drawn = !s.disabled && [s.label, text].some((l) => modelEdges.get(plain)?.labels.has(l)); + return { ...s, text, edge: drawn ? plain : `${plain}${SEP}second` }; } -// A bug of the second model that no current bug matches numbers after the current bugs. +const withPath = (c, m) => { + const path = readerPath(c.trace, m.key); + return { ...c, path, end: path[path.length - 1].key }; +}; +const classesById = group(traces, main); +const mainClasses = [...classesById.values()] + .sort(byOrder) + .map((c) => ({ ...withPath(c, main), second: divergence(replaySecond(c.trace)) })); const introduced = second - ? [...group(secondTraces, second).values()].filter((c) => !classesById.has(c.id)).sort(byOrder) + ? [...group(secondTraces, second).values()] + .filter((c) => !classesById.has(c.id)) + .sort(byOrder) + .map((c) => ({ ...withPath(c, second), second: null, introduced: true })) : []; -for (const c of introduced) { - c.path = readerPath(c.trace, second.key); - c.end = c.path[c.path.length - 1].key; - c.second = null; - c.introduced = true; -} -classes.push(...introduced); +const classes = [...mainClasses, ...introduced]; -// ---------- status ---------- -// A chart state is bad when it holds a state where a rule breaks, or when every path from the start -// to it passes such a state: what the reader sees after a bug is part of that bug. TLC stops each -// path at the first state that breaks the rule, so a later state that breaks it again does not -// color its chart state when a path without a bug also reaches that chart state. -// With a fix, a state is still wrong when it is bad in the fixed model, and fixed when it is bad only -// in the current model; without --fixed-traces every bad state is wrong. -// Against a spec, a state is wrong when it is bad in either model, and green when only the spec has it. const diverges = (c) => c.second?.key !== undefined; function badKeys(m, keys, raw) { const g = m.graph; @@ -610,6 +599,7 @@ const badSecond = ? badNow : new Set(); const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); +const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); const onlySecond = (k) => second && !now.nodes.has(k); const status = (k) => { if (role === "against") @@ -619,60 +609,39 @@ const status = (k) => { return badNow.has(k) ? "wrong" : "works"; }; -const edges = new Map(now.edges); -if (secondQ) { - const only = (l) => `${within} ${L}, ${l}`; - for (const [id, e] of secondQ.edges) { - if (!edges.has(id)) - edges.set(id, { - ...e, - labels: new Set([...e.labels].map(only)), - guards: new Map([...e.guards].map(([l, words]) => [only(l), words])), - secondOnly: true, - }); - } -} -// The second model shows the reader one step of the bug differently. Its edge leaves the chart state -// before that step and ends where the second model goes instead, or stays put when the second model -// cannot take the step. A step that the chart already draws there, in the same words, adds no edge. -for (const c of classes) { - if (!diverges(c)) continue; - const s = c.second; - const plain = `${s.from}${SEP}${SEP}${s.key}`; - s.text = `${within} ${L}, ${s.label}${s.disabled ? " cannot happen" : ""}`; - if (!s.disabled && [s.label, s.text].some((l) => edges.get(plain)?.labels.has(l))) { - s.edge = plain; - continue; - } - s.edge = `${plain}${SEP}second`; - if (!edges.has(s.edge)) - edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); - edges.get(s.edge).labels.add(s.text); -} - -// ---------- highlighted path ---------- const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; if (args.highlight > 0 && classes.length > 0 && !chosen) throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); -const pathNumbers = new Map(); -if (chosen) { - chosen.path.slice(1).forEach((p, i) => { - const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; - if (!pathNumbers.has(id)) pathNumbers.set(id, []); - pathNumbers - .get(id) - .push({ n: i + 1, label: p.label, shown: p.guard ? `${p.label} [${p.guard}]` : p.label }); - if (!edges.has(id)) - edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); +if (chosen && diverges(chosen)) onPath.add(chosen.second.key); + +const edgesAlong = (c) => + c.path.slice(1).map((p, i) => ({ + id: edgeId(c.path[i].key, p.key), + from: c.path[i].key, + to: p.key, + label: p.label, + guard: p.guard, + })); +function chartEdges() { + const edges = new Map(modelEdges); + for (const { second: s } of classes.filter(diverges)) { + if (modelEdges.has(s.edge)) continue; + if (!edges.has(s.edge)) + edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); + edges.get(s.edge).labels.add(s.text); + } + const pathNumbers = new Map(); + (chosen ? edgesAlong(chosen) : []).forEach((step, i) => { + if (!pathNumbers.has(step.id)) pathNumbers.set(step.id, []); + const shown = step.guard ? `${step.label} [${step.guard}]` : step.label; + pathNumbers.get(step.id).push({ n: i + 1, label: step.label, shown }); + if (!edges.has(step.id)) + edges.set(step.id, { from: step.from, to: step.to, labels: new Set([step.label]) }); }); - if (diverges(chosen)) onPath.add(chosen.second.key); -} + if (!args.cover) return { edges, pathNumbers, coverEdges: null }; -// ---------- cover: shortest path to each settled reader state ---------- -let coverEdges = null; -if (args.cover) { - const keepEdges = new Set(); + const coverEdges = new Set(); const bfs = (q, targets) => { const parent = new Map([...q.initial].map((k) => [k, null])); const queue = [...q.initial]; @@ -690,7 +659,7 @@ if (args.cover) { } } for (const t of targets) { - for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) coverEdges.add(id); } }; bfs(now, now.settled); @@ -701,23 +670,21 @@ if (args.cover) { ); const pathLabels = new Map(); for (const c of classes) { - c.path.slice(1).forEach((p, i) => { - const id = `${c.path[i].key}${SEP}${SEP}${p.key}`; - keepEdges.add(id); - pathLabels.set(id, (pathLabels.get(id) ?? new Set()).add(p.label)); - }); - if (diverges(c)) keepEdges.add(c.second.edge); - } - for (const id of keepEdges) { - if (!edges.has(id)) { - const [from, to] = id.split(`${SEP}${SEP}`); - edges.set(id, { from, to, labels: pathLabels.get(id) ?? new Set() }); + for (const step of edgesAlong(c)) { + coverEdges.add(step.id); + pathLabels.set(step.id, (pathLabels.get(step.id) ?? new Set()).add(step.label)); } + if (diverges(c)) coverEdges.add(c.second.edge); } - coverEdges = keepEdges; + for (const id of coverEdges) { + if (edges.has(id)) continue; + const [from, to] = id.split(`${SEP}${SEP}`); + edges.set(id, { from, to, labels: pathLabels.get(id) }); + } + return { edges, pathNumbers, coverEdges }; } +const { edges, pathNumbers, coverEdges } = chartEdges(); -// ---------- Mermaid ---------- const LEGEND = { works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")], wrong: [ @@ -730,14 +697,10 @@ const LEGEND = { ], }; -// ---------- groups ---------- -// The mapping's group() names the group of each model state, and a chart state takes the group -// that all its model states share. The fill shows the group. The status from TLC stays a border on -// top, so no group hides a broken rule or the second model's states. const grouping = typeof mapping.group === "function"; const groupsOf = new Map(); const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : ""); -let groupNames = []; +const groupNames = []; const fills = new Map(); if (grouping) { const add = (k, vars, m) => { @@ -749,7 +712,7 @@ if (grouping) { }; for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); if (second) - for (const n of secondGraph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); + for (const n of second.graph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); const given = mapping.groupColors ?? {}; if (!isRecord(given)) @@ -770,10 +733,10 @@ if (grouping) { throw new Error( `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`, ); - groupNames = [ + groupNames.push( ...Object.keys(given).filter((g) => found.has(g)), ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(), - ]; + ); for (const [g, color] of Object.entries(given)) { if (color === undefined || color === null || color === "") continue; const why = groupColorProblem(color); @@ -818,7 +781,6 @@ const frontMatter = (title) => [ " useMaxWidth: false", "---", ]; -// drawn holds { group, status, cls } for each state the chart draws. function head(drawn) { const out = [...frontMatter(args.title), "stateDiagram-v2"]; if (role) out.push(` %% second model (${role}): ${L}`); @@ -834,11 +796,11 @@ function head(drawn) { ] : used.map((s) => [...LEGEND[s], s]); if (grouping) { - const classes = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); + const styleClasses = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])]; for (const [base, fill] of bases) for (const border of ["", "Wrong", "Fixed", "Path"]) - if (classes.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); + if (styleClasses.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); } else for (const c of STATUS_CLASSES) out.push(` ${c}`); if (legend.length) { out.push(` state "Legend" as ${LEGEND_ID} {`); @@ -849,20 +811,14 @@ function head(drawn) { return out; } -// ---------- parallel regions ---------- -// Splits each "context: progress" label into a context region and a progress region. A step of -// one region that happens beside only some states of the other gets a guard. The chart is used -// only when it has fewer states than the flat chart, and when, read back, its combined machine -// has exactly the flat chart's states, start states, and labeled steps. -function regionsChart() { +function regionsChart({ states, starts, edges, classes, proofPath, allPath }) { const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim(); const split = (k) => { const at = k.indexOf(": "); return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null; }; - const nodes = [...allNodes].sort(); + const nodes = [...states].sort(); if (!nodes.every(split)) return null; - const starts = [...new Set([...now.initial, ...(secondQ?.initial ?? [])])]; const steps = []; for (const e of edges.values()) { if (e.labels.size === 0) return { why: "a step has no label" }; @@ -890,9 +846,8 @@ function regionsChart() { ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), ]; - if (args.proof) fs.writeFileSync(args.proof, proof.join("\n") + "\n"); + if (proofPath) fs.writeFileSync(proofPath, proof.join("\n") + "\n"); - // Each region state, and the states of the other region that it is part of a state with. const beside = [new Map(), new Map()]; for (const k of nodes) { const p = split(k); @@ -905,8 +860,6 @@ function regionsChart() { if (size >= nodes.length) return { why: `${size} region states are not fewer than the ${nodes.length} flat states` }; - // A region state's status is the lowest status of the states it is part of, and each state must - // then show the higher status of its two region states. const rank = ["works", "fixed", "wrong"]; const regionStatus = [new Map(), new Map()]; for (const k of nodes) { @@ -926,8 +879,6 @@ function regionsChart() { }; } - // A region takes every step it has, so a step that leaves a region state in some states and keeps - // it in others needs a loop there: the loop says the region may also stay. const moves = [new Map(), new Map()]; for (const s of steps) for (const i of [0, 1]) { @@ -951,7 +902,6 @@ function regionsChart() { ) m.delete(id); const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`]))); - // A region state is in a group when every state it is part of is in that group. const regionGroup = [new Map(), new Map()]; for (const k of nodes) { const p = split(k); @@ -963,8 +913,7 @@ function regionsChart() { const drawn = rid.map((m, i) => [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))), ); - // keep holds the ids of the arrows to draw in each region, or is null for every arrow. - const draw = (keep) => { + const draw = (only) => { const out = head(drawn.flat()); out.push(` state "At the same time" as regions {`); let count = 0; @@ -976,13 +925,11 @@ function regionsChart() { for (const s of new Set(starts.map((k) => split(k)[i]))) out.push(`${pad}[*] --> ${rid[i].get(s)}`); for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); - const sorted = [...moves[i]] - .filter(([id]) => !keep || keep[i].has(id)) + const shownMoves = [...moves[i]] + .filter(([id]) => !only || only[i].has(id)) .map(([, m]) => m) .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label)); - for (const m of sorted) { - // The guard names the shorter of the states the step happens beside, or those it does not. - // The mapping's guard words, when it has them, replace those names in the label. + for (const m of shownMoves) { const all = beside[i].get(m.from); const not = [...all].filter((s) => !m.beside.has(s)).sort(); const yes = [...m.beside].sort(); @@ -1016,13 +963,8 @@ function regionsChart() { why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`, }; } - if (args.regionsAll) fs.writeFileSync(args.regionsAll, all.mermaid); + if (allPath) fs.writeFileSync(allPath, all.mermaid); - // The overview keeps each bug path, then a way from the start to each region state and one step - // with each label, as the arrows of the regions that each step moves. It adds the cheapest of - // those next: the fewest new arrows, then the fewest steps. A region with fewer arrows can stay - // where the full chart moves it, so each step that the kept arrows make and TLC's graph does - // not have gets back every arrow that the full chart takes there. const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)])); const key = (from, label, to) => `${from} --> ${to} : ${label}`; const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to)))); @@ -1051,10 +993,10 @@ function regionsChart() { })); }; for (const c of classes) { - c.path.slice(1).forEach((p, j) => { - const e = edges.get(`${c.path[j].key}${SEP}${SEP}${p.key}`); - if (e) stepsOf(e, norm(p.label)).forEach(want); - }); + for (const step of edgesAlong(c)) { + const e = edges.get(step.id); + if (e) stepsOf(e, norm(step.label)).forEach(want); + } if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want); } const cheapest = () => { @@ -1113,14 +1055,14 @@ function regionsChart() { for (;;) { scoped = draw(keep); made = productOf(parseMermaid(scoped.mermaid).regions); - const drawn = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); + const madeKeys = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); const redo = [ ...made.transitions .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to))) .map((t) => [parts.get(t.from), t.label]), ...wanted .filter((s) => made.states.has(bar(s.from))) - .filter((s) => !drawn.has(key(bar(s.from), s.label, bar(s.to)))) + .filter((s) => !madeKeys.has(key(bar(s.from), s.label, bar(s.to)))) .map((s) => [s.from, s.label]), ]; const before = keep[0].size + keep[1].size; @@ -1147,26 +1089,30 @@ function regionsChart() { groups: groupCounts(drawn.flat()), }; } -const regions = args.regions ? regionsChart() : null; +const regions = args.regions + ? regionsChart({ + states: allNodes, + starts: [...initial], + edges, + classes, + proofPath: args.proof, + allPath: args.regionsAll, + }) + : null; if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`); -let keep = allNodes; -if (args.pathOnly && chosen) keep = onPath; -else if (coverEdges) { - keep = new Set([...now.initial, ...(secondQ?.initial ?? [])]); - for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); -} -const ids = new Map(); -let counter = 0; -const idOf = (k) => { - if (!ids.has(k)) ids.set(k, `s${counter++}`); - return ids.get(k); -}; -const sorted = [...keep].sort(); +const flatStates = + args.pathOnly && chosen + ? onPath + : coverEdges + ? new Set([ + ...initial, + ...[...coverEdges].flatMap((id) => [edges.get(id).from, edges.get(id).to]), + ]) + : allNodes; +const sorted = [...flatStates].sort(); const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k))); -// Boxes: the states of one outer layer (layered view), or, in the customer view, the states whose -// labels read "context: progress" with the same context. A context with one state stays a plain state. const boxes = new Map(); const boxOf = new Map(); const inBox = (k, box) => { @@ -1183,10 +1129,12 @@ else if (args.view === "customer" && !args.flat) { } const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`])); for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); +const declared = [...[...boxes.values()].flat(), ...sorted.filter((k) => !boxOf.has(k))]; +const stateIds = new Map(declared.map((k, i) => [k, `s${i}`])); +const idOf = (k) => stateIds.get(k); const caption = (k) => args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; -// Consecutive steps of one arrow with the same words share a line, as in "2, 3. you look". function numbered(steps) { const runs = []; for (const { n, shown } of steps) { @@ -1197,16 +1145,12 @@ function numbered(steps) { return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`); } const drawnLabels = new Set(); -// Mermaid stacks the loops on a state in one place, so two states get one arrow. A blank line sets -// its numbered steps, the current model's steps, and the second model's steps apart, since a -// wrapped line looks like a new one. The blank line holds a no-break space, because Mermaid drops -// an empty line. const arrows = new Map(); for (const [id, e] of edges) { - if (!keep.has(e.from) || !keep.has(e.to)) continue; + if (!flatStates.has(e.from) || !flatStates.has(e.to)) continue; if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue; if (coverEdges && !coverEdges.has(id)) continue; - const pair = `${e.from}${SEP}${SEP}${e.to}`; + const pair = edgeId(e.from, e.to); if (!arrows.has(pair)) arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] }); const a = arrows.get(pair); @@ -1224,7 +1168,7 @@ for (const [id, e] of edges) { a.labels.push(...all); for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l); } -const lines = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ +const transitions = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ from, to, // ";" ends a Mermaid statement, even inside a transition label. @@ -1240,15 +1184,12 @@ const lines = [...arrows.values()].map(({ from, to, groups, plain, labels }) => plain, labels: labels.sort(), })); -const edgeCount = lines.length; -// One arrow from a box's border stands for the same step from every state in the box. It is drawn -// only when every state in the box has that step, with the same labels, to the same state outside -// the box, so the chart still says exactly what each state does. const fromBox = []; +const folded = new Set(); for (const [box, members] of boxes) { const mine = new Set(members); const byStep = new Map(); - for (const l of lines) { + for (const l of transitions) { if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue; const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`; if (!byStep.has(step)) byStep.set(step, []); @@ -1256,7 +1197,7 @@ for (const [box, members] of boxes) { } for (const same of byStep.values()) { if (new Set(same.map((l) => l.from)).size !== members.length) continue; - for (const l of same) lines.splice(lines.indexOf(l), 1); + for (const l of same) folded.add(l); fromBox.push({ ...same[0], box }); } } @@ -1269,19 +1210,17 @@ for (const [box, members] of boxes) { } for (const k of sorted) if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); -const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`; for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); -for (const l of lines) out.push(arrow(idOf(l.from), l)); +for (const l of transitions) if (!folded.has(l)) out.push(arrow(idOf(l.from), l)); sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); const mermaid = regions?.mermaid ?? out.join("\n") + "\n"; if (args.out) fs.writeFileSync(args.out, mermaid); else process.stdout.write(mermaid); -// ---------- stories and summary ---------- const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; function secondLine(c) { @@ -1329,7 +1268,6 @@ if (args.story) { }); fs.writeFileSync(args.story, lines.join("\n")); } -// Labels a product reader would not say: camelCase, snake_case, calls, and joined PascalCase words. const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/; const codeLike = [ ...new Set([ @@ -1354,14 +1292,14 @@ const summary = { ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), modelStates: graph.nodes.size, modelTransitions: graph.edges.length, - chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : keep.size, - chartTransitions: regions?.mermaid ? regions.transitions : edgeCount, + chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size, + chartTransitions: regions?.mermaid ? regions.transitions : transitions.length, chartStatus: regions?.mermaid ? regions.status : Object.fromEntries( ["works", "wrong", "fixed"].map((s) => [ s, - [...keep].filter((k) => status(k) === s).length, + [...flatStates].filter((k) => status(k) === s).length, ]), ), ...(grouping @@ -1405,5 +1343,5 @@ const drawnAs = regions?.mermaid : ""; console.error( `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` + - `${classes.length - introduced.length} bug(s)${extraBugs}`, + `${mainClasses.length} bug(s)${extraBugs}`, ); diff --git a/pstack/skills/statechart/scripts/chart-to-tla.mjs b/pstack/skills/statechart/scripts/chart-to-tla.mjs index dacbedd94..7dfa337d2 100644 --- a/pstack/skills/statechart/scripts/chart-to-tla.mjs +++ b/pstack/skills/statechart/scripts/chart-to-tla.mjs @@ -81,7 +81,6 @@ const vars = Object.entries(regions).map(([name, r]) => { return { name, v: ident(name, true), ...r }; }); -// A guard reads the other regions in the state before the step. function guardOf(g) { if (!g) return null; const parts = Object.entries(g.states).map(([name, states]) => { @@ -91,7 +90,6 @@ function guardOf(g) { return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ "); } -// One action per label. Unlabeled transitions get one action each. const actions = []; const byLabel = new Map(); for (const [ri, r] of vars.entries()) { @@ -115,7 +113,7 @@ for (const a of actions) { } const out = []; -const bullet = (indent, op, lines) => lines.map((l, i) => `${" ".repeat(indent)}${op} ${l}`); +const conjuncts = (lines) => lines.map((l) => ` /\\ ${l}`); out.push(`---- MODULE ${args.module} ----`); out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); out.push("\\* Each region is one variable and each transition label is one action."); @@ -130,60 +128,42 @@ out.push(""); for (const x of vars) out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); out.push(""); -out.push( - "TypeOK ==", - ...bullet( - 4, - "/\\", - vars.map((x) => `${x.v} \\in ${x.v}States`), - ), -); +out.push("TypeOK ==", ...conjuncts(vars.map((x) => `${x.v} \\in ${x.v}States`))); out.push(""); out.push( "Init ==", - ...bullet( - 4, - "/\\", + ...conjuncts( vars.map((x) => x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`, ), ), ); +const enabled = (v, m) => `${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""}`; +function whetherTakes(a, ri) { + const { v } = vars[ri]; + const moves = a.moves.get(ri); + if (moves.every((m) => !m.when)) { + const from = set(new Set(moves.map((m) => m.from))); + return { can: `${v} \\in ${from}`, cannot: `${v} \\notin ${from}` }; + } + const any = moves.map((m) => `(${enabled(v, m)})`).join(" \\/ "); + return { can: any, cannot: `~(${any})` }; +} for (const a of actions) { out.push(""); if (a.label) out.push(`\\* ${a.label}`); out.push(`${a.name} ==`); const moving = [...a.moves.keys()]; - // Whether region ri can take the action, or with `not`, cannot. - const can = (ri, not) => { - const v = vars[ri].v; - const moves = a.moves.get(ri); - if (moves.every((m) => !m.when)) - return `${v} ${not ? "\\notin" : "\\in"} ${set(new Set(moves.map((m) => m.from)))}`; - const any = moves - .map((m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""})`) - .join(" \\/ "); - return not ? `~(${any})` : any; - }; - const conj = []; - if (moving.length > 1) conj.push(`(${moving.map((ri) => can(ri, false)).join(" \\/ ")})`); - const blocks = []; + const shared = moving.length > 1; + if (shared) out.push(` /\\ (${moving.map((ri) => whetherTakes(a, ri).can).join(" \\/ ")})`); for (const ri of moving) { - const v = vars[ri].v; - const ds = a.moves - .get(ri) - .map( - (m) => `(${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""} /\\ ${v}' = ${str(m.to)})`, - ); - if (moving.length > 1) ds.push(`(${can(ri, true)} /\\ UNCHANGED ${v})`); - blocks.push(ds); - } - const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); - for (const c of conj) out.push(` /\\ ${c}`); - for (const ds of blocks) { + const { v } = vars[ri]; + const ds = a.moves.get(ri).map((m) => `(${enabled(v, m)} /\\ ${v}' = ${str(m.to)})`); + if (shared) ds.push(`(${whetherTakes(a, ri).cannot} /\\ UNCHANGED ${v})`); out.push(` /\\ \\/ ${ds[0]}`); for (const d of ds.slice(1)) out.push(` \\/ ${d}`); } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); } out.push(""); diff --git a/pstack/skills/statechart/scripts/compare-charts.mjs b/pstack/skills/statechart/scripts/compare-charts.mjs index 3ddb3311a..587125a9f 100644 --- a/pstack/skills/statechart/scripts/compare-charts.mjs +++ b/pstack/skills/statechart/scripts/compare-charts.mjs @@ -83,19 +83,14 @@ for (const [bad, why] of textRefusals) { } const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions); const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions); -const drawn = Object.values(actualRegions).flatMap((r) => r.transitions); - -const refusals = [ - [ - (l) => /^\d+(, \d+)*\. /.test(l), - "an edge label is a numbered path step; draw ACTUAL with --highlight 0", - ], -]; -for (const [bad, why] of refusals) { - if (drawn.some((t) => bad(t.label))) { - console.error(`${actualFile}: ${why}`); - process.exit(2); - } +const numbered = Object.values(actualRegions) + .flatMap((r) => r.transitions) + .some((t) => /^\d+(, \d+)*\. /.test(t.label)); +if (numbered) { + console.error( + `${actualFile}: an edge label is a numbered path step; draw ACTUAL with --highlight 0`, + ); + process.exit(2); } const rows = compareCharts(expectedRegions, actualRegions); diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index 090c1a8d1..cf8d03823 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -1,9 +1,6 @@ -// Shared code for the statechart scripts. Node built-ins only. import fs from "node:fs"; import process from "node:process"; -// ---------- Where TLC is ---------- - // tla2tools.jar and the java binary, from TLA2TOOLS and JAVA. Throws where to get TLC when TLA2TOOLS is not set. export function findTlc() { const { TLA2TOOLS, JAVA } = process.env; @@ -15,8 +12,6 @@ export function findTlc() { return { jar: TLA2TOOLS, java: JAVA || "java" }; } -// ---------- TLA+ values as TLC prints them ---------- - export function parseValue(text) { const src = text.trim(); let i = 0; @@ -112,7 +107,7 @@ export function parseValue(text) { } // One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. -export function parseState(text) { +function parseState(text) { const lines = text.split("\n").filter((l) => l.trim() !== ""); const conjuncts = []; for (const line of lines) { @@ -140,10 +135,8 @@ const canon = (v) => .map((k) => [k, canon(v[k])]), ) : v; -// Record fields sort at every depth, so a state keeps its key when a field is added back. export const stateKey = (vars) => JSON.stringify(canon(vars)); -// The rule sentence of each definition: the \* comment lines right above NAME ==. export function ruleSentences(tla) { const out = new Map(); const lines = tla.split("\n"); @@ -159,8 +152,6 @@ export function ruleSentences(tla) { return out; } -// ---------- TLC -dump dot,actionlabels ---------- - function unescapeDot(s) { let out = ""; for (let i = 0; i < s.length; i++) { @@ -172,8 +163,6 @@ function unescapeDot(s) { return out; } -// Returns { nodes, edges, loops }. loops are steps that change no variable; they count as enabled -// actions but are not transitions. export function parseDot(text) { const nodes = new Map(); const edges = []; @@ -214,9 +203,6 @@ export function parseDot(text) { return { nodes, edges, loops }; } -// ---------- TLC error traces (one per violation; many with -continue) ---------- - -// A trace that follows an error other than a broken invariant has invariant null. export function parseTraces(text) { const traces = []; let invariant = null; @@ -254,8 +240,8 @@ export function parseTraces(text) { return traces; } -// Throws unless TLC finished and every error it printed is a broken invariant. With -continue, TLC -// prints "Model checking completed" even after broken invariants, and not when it stopped early. +// With -continue, TLC prints "Model checking completed" even after broken invariants, and not +// when it stopped early. export function checkTlcOutput(text, file) { const other = text .split("\n") @@ -274,8 +260,6 @@ export function checkTlcOutput(text, file) { ); } -// ---------- Mermaid stateDiagram-v2 (the subset these scripts write and read) ---------- - export function wrap(text, width = 26) { const words = String(text).split(/\s+/); const rows = []; @@ -290,8 +274,6 @@ export function wrap(text, width = 26) { return rows.join("
"); } -// The shared words stop before a word that opens a phrase, so each choice keeps its whole phrase, -// as in "you tap the Pay button or the Back button". const OPENS_A_PHRASE = new Set( "a an the your my our their its this that to of in on at for with from by and or".split(" "), ); @@ -318,19 +300,9 @@ const unwrap = (s) => // Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. export const quote = (s) => String(s).replaceAll('"', "#quot;"); -// The scripts draw their legend as a composite with this id. It is not part of the machine. export const LEGEND_ID = "legend"; -// Marks a chart whose edge labels are written in full, which a chart comparison needs. export const FULL_LABELS = "%% labels in full"; -// Returns { regions: { name: { states:Set, initial:Set, transitions:[{from,to,label,guard?}] } }, nevers }. -// Composite states flatten to "Parent.Child". A transition on a composite leaves from each state -// inside it that has no transition with the same label, and enters at the composite's [*] start. -// One top-level composite split by "--" makes one region per part. Regions nest only at the top. -// Words in [ ] in a label say when the step can happen. They are not part of the label, and nothing -// checks them. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition guards it -// on the states of other regions; its label must have the guard's words in [ ]. -// guard is { not, states: { region: [state] } }. export function parseMermaid(text) { const names = new Map(); const nevers = []; @@ -340,7 +312,6 @@ export function parseMermaid(text) { const stack = [root]; const part = () => stack[stack.length - 1].parts.at(-1); const display = (id) => names.get(id) ?? id; - // As in Mermaid, a state that only transitions name belongs to the part where it first appears. const implicit = new Map(); const declare = (id) => { const prev = implicit.get(id); @@ -426,7 +397,6 @@ export function parseMermaid(text) { } m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); if (m) { - // Mermaid shows every description line; the first one names the state. if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); if (implicit.get(m[1]) === null) continue; declare(m[1]); @@ -442,8 +412,6 @@ export function parseMermaid(text) { ); // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. - // A composite's full name prefixes its children ("Parent.Child"). An id used only in a - // transition is a plain state in the part where it first appears. function region(p) { const out = { states: new Set(), initial: new Set(), transitions: [] }; const info = new Map(); @@ -506,8 +474,6 @@ export function parseMermaid(text) { states(p, "", 0); for (const t of p.transitions) if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); - // The deepest source wins: a composite's transition leaves only from states inside it - // that have no deeper transition with the same label. transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); const handled = new Set(); let pending = []; @@ -532,12 +498,10 @@ export function parseMermaid(text) { if (t.label) pending.push(key); } } - for (const [id, { all }] of info) owners.set(id, { name: regionName, all }); - return out; + return [out, info]; } - const owners = new Map(); - let regionName = ""; + const owners = new Map(); const regions = {}; const top = root.parts[0]; const machine = top.children.filter((c) => c.name !== LEGEND_ID); @@ -557,12 +521,15 @@ export function parseMermaid(text) { : null; if (holder && holder.parts.length > 1) throw new Error(`${display(holder.name)} splits into regions below the top level`); - regionName = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; - regions[regionName] = region(holder ? holder.parts[0] : p); + const name = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; + const [r, ids] = region(holder ? holder.parts[0] : p); + regions[name] = r; + for (const [id, { all }] of ids) owners.set(id, { name, all }); } } else { - regionName = "Main"; - regions.Main = region(top); + const [r, ids] = region(top); + regions.Main = r; + for (const [id, { all }] of ids) owners.set(id, { name: "Main", all }); } for (const [name, r] of Object.entries(regions)) for (const t of r.transitions) { @@ -586,19 +553,12 @@ export function parseMermaid(text) { return { regions, nevers }; } -// Whether a guard lets a step happen while the regions are in the states that `at` maps them to. -export function guardHolds(guard, at) { +function guardHolds(guard, at) { if (!guard) return true; const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r])); return guard.not ? !hits.some(Boolean) : hits.every(Boolean); } -// The combined machine of a chart's regions, as chart-to-tla.mjs generates it. A labeled step can -// happen when at least one region can take it; every region that can take it moves, the others stay. -// A region can take a transition only while its guard holds in the state before the step. -// An unlabeled transition moves its own region only. A combined state is the region states joined -// by " | " in region order. Only states reachable from the start are kept, and steps that change -// nothing are dropped, as TLC's state graph drops them. export function productOf(regions) { const names = Object.keys(regions); const rs = Object.values(regions); @@ -661,9 +621,6 @@ export function productOf(regions) { return out; } -// Compares two charts as the machines their steps reach. Returns [title, differences] rows, only -// the rows that have differences. An edge label "a or b" counts as two labels. An unlabeled edge -// counts only when no labeled edge joins the same two states. export function compareCharts(expectedRegions, actualRegions) { const expected = productOf(expectedRegions); const actual = productOf(actualRegions); @@ -680,7 +637,6 @@ export function compareCharts(expectedRegions, actualRegions) { return out; }; const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); - // A region state that no combination of the agreed chart's own steps reaches can never be drawn. const unreached = []; const regionEntries = Object.entries(expectedRegions); if (regionEntries.length === 1) { @@ -717,8 +673,6 @@ export const STATUS_CLASSES = [ "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", ]; -// Fills for the mapping's groups that it gives no color: light, and not red, green, or gray, so a -// status border stays clear on each. export const GROUP_PALETTE = [ "#d6eaf8", "#fdebd0", @@ -735,14 +689,13 @@ const rgb = (hex) => { return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); }; // WCAG relative luminance: 0 for black, 1 for white. -export function luminance(hex) { +function luminance(hex) { const [r, g, b] = rgb(hex).map((c) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } -// Why a group color cannot fill a state, or null when it can. export function groupColorProblem(color) { if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) return 'is not "#rgb" or "#rrggbb"'; @@ -750,7 +703,6 @@ export function groupColorProblem(color) { return null; } -// A status border sits on top of the group fill. The path border shows only on states that work. const BORDERS = { Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"], Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"], diff --git a/pstack/skills/statechart/scripts/model/OutputFolder.cfg b/pstack/skills/statechart/scripts/model/OutputFolder.cfg new file mode 100644 index 000000000..68fd715a8 --- /dev/null +++ b/pstack/skills/statechart/scripts/model/OutputFolder.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +INVARIANT ChartsOneSpec +INVARIANT KeepsYourFiles +INVARIANT NeverLosesChart +INVARIANT ExitTwoLeavesFolder +INVARIANT OverlapNeverRefuses diff --git a/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs b/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs new file mode 100644 index 000000000..9618338f6 --- /dev/null +++ b/pstack/skills/statechart/scripts/model/OutputFolder.map.mjs @@ -0,0 +1,106 @@ +const QUIET = ["idle", "exited", "stopped", "orphanTlc"]; +const going = (v) => v.pc.filter((p) => !QUIET.includes(p)).length; +const changed = (before, after) => + after.pc.findIndex( + (p, i) => p !== before.pc[i] || after.aside[i].exists !== before.aside[i].exists, + ) + 1; + +export const readerActions = ["Start", "Edit", "Note", "Stop", "KillNode"]; + +function context(v) { + if (v.pc.every((p) => p === "idle")) return "Before any run"; + if (going(v) === 2) return "Two runs going"; + return going(v) === 1 ? "One run going" : "Done"; +} + +const SPECS = { 1: "the spec", 2: "the edited spec", "1,2": "both specs" }; + +function contents(v) { + const kept = v.aside.filter((a) => a.exists); + const aside = kept.some((a) => a.notes) + ? ", your notes moved aside" + : kept.some((a) => a.by) + ? ", the old chart moved aside" + : ""; + const parts = [ + v.out.by && `chart of ${SPECS[v.out.read.join()]}`, + v.out.notes && "your notes", + ].filter(Boolean); + if (!v.out.exists) return `no folder${aside}`; + return `${parts.join(" and ") || "empty folder"}${aside}`; +} + +export function state(v) { + return `${context(v)}: ${contents(v)}`; +} + +export function group(v) { + if (v.pc.every((p) => p === "idle")) return "Not started"; + return going(v) ? "Running" : "Finished"; +} + +export function step(action, before, after) { + const r = changed(before, after); + switch (action) { + case "Start": { + const where = going(before) ? " in a second shell" : ""; + const how = after.inputOk[r - 1] ? "" : " with a typo"; + return `you start run ${r}${where}${how}`; + } + case "Refuse": + return before.inputOk[r - 1] + ? `run ${r} says the folder has a file that it did not write` + : `run ${r} says the input is wrong`; + case "Tlc": + return `run ${r}'s TLC reads the spec`; + case "Draw": + return `run ${r} draws the chart in its own folder`; + case "Stale": + return `run ${r} says the spec changed while it read it`; + case "Foreign": + return `run ${r} says the folder has a file that it did not write`; + case "Swap": + case "Checked": + return ""; + case "MoveIn": + return `run ${r} moves its folder into place`; + case "MoveAside": + return `run ${r} moves the old folder aside`; + case "Restore": + return `run ${r} puts the folder with your notes back and stops`; + case "Supersede": + return `run ${r} finds another run's folder in place and deletes the old folder`; + case "KeepAside": + return `run ${r} keeps the folder with your notes aside and says where`; + case "RenameFails": + if (after.aside[r - 1].exists) + return `run ${r} cannot rename a folder and says where the old folder is`; + return after.out.exists && !before.out.exists + ? `run ${r} cannot rename a folder, puts the old folder back, and stops` + : `run ${r} cannot rename a folder and stops`; + case "Clean": + return `run ${r} deletes the old folder and finishes`; + case "Edit": + return "you edit the spec"; + case "Note": + return "you save notes in the folder"; + case "Stop": + return `you stop run ${r} with Ctrl-C`; + case "KillNode": + return `you kill run ${r}'s Node, and its TLC keeps running`; + case "OrphanEnd": + return `run ${r}'s orphaned TLC finishes`; + default: + return action; + } +} + +export function cause(v, facts, invariant) { + return { + ChartsOneSpec: "the run read the spec before and after an edit, and still wrote its chart", + KeepsYourFiles: "a run deleted a folder that had your notes in it", + NeverLosesChart: "a run deleted the last chart before another chart was in place", + ExitTwoLeavesFolder: "a run wrote the folder and then said it could not finish", + OverlapNeverRefuses: "a run stopped only because another run overlapped it", + }[invariant]; +} diff --git a/pstack/skills/statechart/scripts/model/OutputFolder.tla b/pstack/skills/statechart/scripts/model/OutputFolder.tla new file mode 100644 index 000000000..7765babcb --- /dev/null +++ b/pstack/skills/statechart/scripts/model/OutputFolder.tla @@ -0,0 +1,176 @@ +---- MODULE OutputFolder ---- +EXTENDS Naturals + +Runs == {1, 2} +NoRun == 0 +NoFolder == [exists |-> FALSE, by |-> NoRun, read |-> {}, notes |-> FALSE] + +VARIABLES pc, inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode + +vars == <> + +Init == + /\ pc = [r \in Runs |-> "idle"] + /\ inputOk = [r \in Runs |-> TRUE] + /\ snap = [r \in Runs |-> 0] + /\ read = [r \in Runs |-> {}] + /\ out = NoFolder + /\ aside = [r \in Runs |-> NoFolder] + /\ spec = 1 + /\ noted = FALSE + /\ wrote = [r \in Runs |-> FALSE] + /\ broke = [r \in Runs |-> FALSE] + /\ exitCode = [r \in Runs |-> "none"] + +Set(f, r, v) == [f EXCEPT ![r] = v] +Going(r) == pc[r] \notin {"idle", "exited", "stopped", "orphanTlc"} +Go(r, to) == pc' = Set(pc, r, to) +Exit(r, code) == pc' = Set(pc, r, "exited") /\ exitCode' = Set(exitCode, r, code) + +Start(r) == + /\ pc[r] = "idle" + /\ r = 2 => pc[1] # "idle" + /\ \E ok \in BOOLEAN : inputOk' = Set(inputOk, r, ok) + /\ snap' = Set(snap, r, spec) + /\ Go(r, "check") + /\ UNCHANGED <> + +Refuse(r) == + /\ pc[r] = "check" + /\ ~inputOk[r] \/ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Tlc(r) == + /\ \/ pc[r] = "check" /\ inputOk[r] /\ ~out.notes + \/ pc[r] = "tlc" + /\ read' = Set(read, r, read[r] \cup {spec}) + /\ Go(r, IF pc[r] = "check" THEN "tlc" ELSE "draw") + /\ UNCHANGED <> + +Draw(r) == + /\ pc[r] = "draw" + /\ Go(r, "publish") + /\ UNCHANGED <> + +Stale(r) == + /\ pc[r] = "publish" /\ spec # snap[r] + /\ Exit(r, "2") + /\ UNCHANGED <> + +Foreign(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Swap(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ ~out.notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +MoveIn(r) == + /\ pc[r] = "swap" /\ ~out.exists + /\ out' = [exists |-> TRUE, by |-> r, read |-> read[r], notes |-> FALSE] + /\ wrote' = Set(wrote, r, TRUE) + /\ Go(r, "clean") + /\ UNCHANGED <> + +MoveAside(r) == + /\ pc[r] = "swap" /\ out.exists /\ ~aside[r].exists + /\ aside' = Set(aside, r, out) + /\ out' = NoFolder + /\ Go(r, "aside") + /\ UNCHANGED <> + +Checked(r) == + /\ pc[r] = "aside" /\ ~aside[r].notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +Supersede(r) == + /\ pc[r] = "swap" /\ out.exists /\ aside[r].exists /\ ~out.notes + /\ aside' = Set(aside, r, NoFolder) + /\ UNCHANGED <> + +Restore(r) == + /\ pc[r] = "aside" /\ aside[r].notes /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "2") + /\ UNCHANGED <> + +KeepAside(r) == + /\ \/ pc[r] = "aside" /\ aside[r].notes /\ out.exists + \/ pc[r] = "swap" /\ aside[r].exists /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +RenameFails(r) == + /\ pc[r] \in {"swap", "aside"} + /\ \/ /\ aside[r].exists /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + \/ UNCHANGED <> + /\ broke' = Set(broke, r, TRUE) + /\ Exit(r, "2") + /\ UNCHANGED <> + +Clean(r) == + /\ pc[r] = "clean" + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "0") + /\ UNCHANGED <> + +Stop(r) == + /\ Going(r) + /\ Go(r, "stopped") + /\ UNCHANGED <> + +KillNode(r) == + /\ pc[r] = "tlc" + /\ Go(r, "orphanTlc") + /\ UNCHANGED <> + +OrphanEnd(r) == + /\ pc[r] = "orphanTlc" + /\ Go(r, "stopped") + /\ UNCHANGED <> + +Edit == + /\ spec = 1 + /\ spec' = 2 + /\ UNCHANGED <> + +Note == + /\ ~noted + /\ noted' = TRUE + /\ out' = [out EXCEPT !.exists = TRUE, !.notes = TRUE] + /\ UNCHANGED <> + +Next == + \/ Edit \/ Note + \/ \E r \in Runs : + Start(r) \/ Refuse(r) \/ Tlc(r) \/ Draw(r) \/ Stale(r) \/ Foreign(r) \/ Swap(r) + \/ MoveIn(r) \/ MoveAside(r) \/ Checked(r) \/ Supersede(r) \/ Restore(r) + \/ KeepAside(r) \/ RenameFails(r) \/ Clean(r) \/ Stop(r) \/ KillNode(r) \/ OrphanEnd(r) + +Spec == Init /\ [][Next]_vars + +\* The folder never holds a chart drawn from two versions of the spec. +ChartsOneSpec == out.read \in {{}, {1}, {2}} + +\* A file that you put in the folder is never deleted. +KeepsYourFiles == noted => out.notes \/ \E r \in Runs : aside[r].notes + +\* Once a run writes a chart, the folder or a folder that a run moved aside always holds one. +NeverLosesChart == + (\E r \in Runs : wrote[r]) => out.by # NoRun \/ \E r \in Runs : aside[r].by # NoRun + +\* A run that exits 2 did not write the folder. +ExitTwoLeavesFolder == \A r \in Runs : exitCode[r] = "2" => ~wrote[r] + +\* A run exits 2 only for a wrong input, an edited spec, a file that it did not write, or a failed +\* rename, never because another run overlaps it. +OverlapNeverRefuses == + \A r \in Runs : exitCode[r] = "2" => ~inputOk[r] \/ spec # snap[r] \/ noted \/ broke[r] +==== diff --git a/pstack/skills/statechart/scripts/statechart.mjs b/pstack/skills/statechart/scripts/statechart.mjs index ada61542f..6e2ebfccd 100644 --- a/pstack/skills/statechart/scripts/statechart.mjs +++ b/pstack/skills/statechart/scripts/statechart.mjs @@ -63,11 +63,15 @@ const FROM_CHART = `from-chart CHART.mmd const COMMON = `Both commands --expect-broken RULE - A rule that is known to break. Repeat it. The command then exits 0 when - exactly these rules break, and the --fixed or --against model breaks no other - rule. + A rule that is known to break. Repeat it, or list rules with commas, as in + A,B. The command then exits 0 when exactly these rules break, and the --fixed + or --against model breaks no other rule. --title TEXT The chart title. Each bug chart adds ", bug N". - --out DIR The output folder. Default: NAME-statechart next to the input. + --out DIR The output folder. Default: NAME-statechart next to the input. A run writes its + files in a temporary folder, and then replaces the whole output folder with + it. When runs overlap, the output folder has the files of the run that + finished last. The command does not use an output folder that has files that + it did not write. --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. -h, --help Print this help, and exit 0. @@ -89,7 +93,7 @@ Output tlc/regions-proof.mmd, every state and step of TLC's graph in one region. compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the proof. - *.png Each chart as a PNG, with --png. + *.png Each chart as a PNG, with --png. A run without --png deletes them. Reading a chart from-spec draws the overview as two parallel regions when every state reads "context: progress": @@ -140,9 +144,13 @@ Exit codes 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not CHART.mmd. The output says which. 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it - cannot read, no TLC, a new mapping to fill in, or a chart that --png cannot render. When a - chart does not render, the command keeps the files it wrote and exits 2, whatever the rules - give.`; + cannot read, no TLC, a helper script that cannot start, an output folder with files that the + command did not write, an input file that changed during the run, a new mapping to fill in, + or a chart that --png cannot render. When a chart does not render, the command keeps the + files it wrote and exits 2, whatever the rules give. Otherwise, exit 2 does not change the + output folder, unless the command moved it aside and cannot put it back. Then the message + names the folder's new path. When TLC stops on an error, the message names TLC's output, in a + temporary folder that the command keeps.`; const HELP = { "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} @@ -163,6 +171,7 @@ const more = (cmd) => `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; class CannotFinish extends Error {} +class TlcStopped extends CannotFinish {} const COMMANDS = { "from-spec": { @@ -185,7 +194,7 @@ function parseArgs([command, ...rest]) { else if (COMMANDS[command].flags.includes(a)) { if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`); const name = a.slice(2); - if (flags[name] instanceof Set) flags[name].add(rest[++i]); + if (flags[name] instanceof Set) for (const v of rest[++i].split(",")) flags[name].add(v); else flags[name] = rest[++i]; } else if (!a.startsWith("-") && input === undefined) input = a; else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`); @@ -209,6 +218,7 @@ function script(name, scriptArgs, about, { okExits = [0] } = {}) { const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { encoding: "utf8", }); + if (r.error) throw new CannotFinish(`cannot run ${name}: ${r.error.message}`); if (okExits.includes(r.status)) return r; const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); @@ -237,10 +247,15 @@ function splitRules(cfg) { function tlc(tools, spec, cfg, base, dump) { fs.writeFileSync(`${base}.cfg`, cfg); const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); + // TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names, + // so runs that share that folder can read a module while another run rewrites it. + const javaTmp = path.join(metadir, "tmp"); + fs.mkdirSync(javaTmp); // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. const r = spawnSync( tools.java, [ + `-Djava.io.tmpdir=${javaTmp}`, "-cp", tools.jar, "tlc2.TLC", @@ -259,9 +274,9 @@ function tlc(tools, spec, cfg, base, dump) { if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); fs.writeFileSync(`${base}.out`, r.stdout); try { - checkTlcOutput(r.stdout, rel(`${base}.out`)); + checkTlcOutput(r.stdout, `${base}.out`); } catch (e) { - throw new CannotFinish(e.message); + throw new TlcStopped(e.message); } return r.stdout; } @@ -302,9 +317,9 @@ function readInput(spec, expected) { return model; } -function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, out, expected) { +function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, work, expected) { const name = path.basename(spec, ".tla"); - const tlcDir = path.join(out, "tlc"); + const tlcDir = path.join(work, "tlc"); const dot = path.join(tlcDir, `${name}.dot`); tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); warnUnnamedSteps(spec, cfgWithoutRules, dot); @@ -401,7 +416,8 @@ export function step(action, before, after) { `; }; -async function draw({ now, other, map, out, title, comparing }) { +async function draw({ now, other, map, work, out, title, comparing }) { + const shown = published(work, out); const mapping = await import(pathToFileURL(path.resolve(map)).href); const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; from.push(...now.traces.flatMap((t) => ["--traces", t.file])); @@ -413,17 +429,14 @@ async function draw({ now, other, map, out, title, comparing }) { if (comparing) from.push("--full-labels", "--flat"); else if (mapping.readerActions?.length) from.push("--cover"); const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []); - for (const f of fs.readdirSync(out)) - if (/^bug-\d+(-developer|-layered)?\.(mmd|png)$/.test(f)) fs.rmSync(path.join(out, f)); const files = { - overview: path.join(out, "chart.mmd"), - stories: path.join(out, "stories.md"), - summary: path.join(out, "summary.json"), + overview: path.join(work, "chart.mmd"), + stories: path.join(work, "stories.md"), + summary: path.join(work, "summary.json"), }; - const proof = path.join(out, "tlc", "regions-proof.mmd"); - const everyArrow = path.join(out, "tlc", "regions-all.mmd"); - for (const f of [proof, everyArrow]) fs.rmSync(f, { force: true }); + const proof = path.join(work, "tlc", "regions-proof.mmd"); + const everyArrow = path.join(work, "tlc", "regions-all.mmd"); script( "tlc-to-chart.mjs", [ @@ -446,7 +459,7 @@ async function draw({ now, other, map, out, title, comparing }) { for (const c of summary.classes) { const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; for (const view of views) { - const file = path.join(out, `bug-${c.n}${view}.mmd`); + const file = path.join(work, `bug-${c.n}${view}.mmd`); const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"]; script( "tlc-to-chart.mjs", @@ -465,7 +478,7 @@ async function draw({ now, other, map, out, title, comparing }) { const r = summary.regions; if (r?.drawn) console.log( - `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 ${rel(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${rel(everyArrow)} has all ${r.all} arrows.`, + `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.`, ); else if (r) console.log(`It has no parallel regions, because ${r.why}.`); const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; @@ -488,7 +501,7 @@ async function draw({ now, other, map, out, title, comparing }) { return { files, charts }; } -function render(charts) { +function render(charts, shown) { const pngs = []; for (const chart of charts) { const png = chart.replace(/\.mmd$/, ".png"); @@ -502,14 +515,15 @@ function render(charts) { if (r.status === 0) pngs.push(png); else console.error( - `could not render ${rel(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, + `could not render ${shown(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, ); } return pngs; } -function finish(out, { files, charts }, ok, png) { - const pngs = png ? render(charts) : []; +function finish({ work, out, inputs }, { files, charts }, ok, png) { + const pngs = png ? render(charts, published(work, out)) : []; + publish(work, out, inputs); const written = [...charts, ...pngs, files.stories, files.summary] .map((f) => path.basename(f)) .join(", "); @@ -518,12 +532,121 @@ function finish(out, { files, charts }, ok, png) { return ok ? 0 : 1; } +const published = (work, out) => (file) => rel(path.join(out, path.relative(work, file))); + +const OWN = + /^((chart|bug-\d+(-developer|-layered)?)\.(mmd|png)|stories\.md|summary\.json|tlc|\.DS_Store)$/; + +function foreign(dir) { + try { + return fs + .readdirSync(dir) + .filter((f) => !OWN.test(f)) + .sort(); + } catch (e) { + if (e.code === "ENOENT") return []; + throw new CannotFinish(`cannot use ${rel(dir)} as the output folder: ${e.message}`); + } +} + +function refuseForeign(out, files = foreign(out)) { + const [first, ...others] = files; + if (first) + throw new CannotFinish( + `${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.`, + ); +} + function outDir(flagged, input, name) { const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`); - fs.mkdirSync(path.join(out, "tlc"), { recursive: true }); + refuseForeign(out); return out; } +const fingerprint = (file) => { + try { + const { ino, ctimeNs } = fs.statSync(file, { bigint: true }); + return `${ino} ${ctimeNs} ${fs.readFileSync(file, "base64")}`; + } catch { + return null; + } +}; + +function snapshot(files, inputs = new Map()) { + for (const f of files) if (f && !inputs.has(f)) inputs.set(f, fingerprint(f)); + return inputs; +} + +async function inWorkFolder(body) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")); + fs.mkdirSync(path.join(work, "tlc")); + let keep = false; + try { + return await body(work); + } catch (e) { + keep = e instanceof TlcStopped; + throw e; + } finally { + if (!keep) fs.rmSync(work, { recursive: true, force: true }); + } +} + +const moved = (from, to, ...missed) => { + try { + fs.renameSync(from, to); + return true; + } catch (e) { + if (missed.includes(e.code)) return false; + throw e; + } +}; + +function publish(work, out, inputs) { + const parent = path.dirname(path.resolve(out)); + fs.mkdirSync(parent, { recursive: true }); + const next = fs.mkdtempSync(path.join(parent, `.${path.basename(out)}-`)); + const old = `${next}-old`; + let aside = false; + try { + fs.cpSync(work, next, { recursive: true }); + const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0]; + if (changed) + throw new CannotFinish( + `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`, + ); + refuseForeign(out); + while (!moved(next, out, "ENOTEMPTY", "EEXIST")) { + if (aside) { + refuseForeign(out); + fs.rmSync(old, { recursive: true, force: true }); + aside = false; + } + aside = moved(out, old, "ENOENT"); + if (aside) refuseForeign(out, foreign(old)); + } + } catch (e) { + const back = !aside || putBack(old, out); + fs.rmSync(next, { recursive: true, force: true }); + const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`; + if (!back) + throw new CannotFinish( + `${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`, + ); + throw e instanceof CannotFinish + ? e + : new CannotFinish(`${why} The run left ${rel(out)} as it was.`); + } + if (aside) fs.rmSync(old, { recursive: true, force: true }); +} + +function putBack(old, out) { + try { + return moved(old, out, "ENOTEMPTY", "EEXIST"); + } catch { + return false; + } +} + function secondModel(input, { fixed, against, label }) { if (fixed && against) throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); @@ -540,28 +663,35 @@ function secondModel(input, { fixed, against, label }) { throw new CannotFinish( `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, ); - return { role, spec, label }; + const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); + return { role, label, model: readModel(spec, cfg) }; } async function fromSpec(input, { "expect-broken": expected, ...flags }) { + const name = path.basename(input, ".tla"); + const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const secondSpec = flags.fixed ?? flags.against; + const inputs = snapshot([input, cfgOf(input), map, secondSpec, secondSpec && cfgOf(secondSpec)]); const second = secondModel(input, flags); const tools = findTools(); - const name = path.basename(input, ".tla"); + const model = readInput(input, expected); const out = outDir(flags.out, input, name); - const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); - const now = check(tools, readInput(input, expected), out, expected); - if (!fs.existsSync(map)) { - fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); - throw new CannotFinish(`\nWrote ${rel(map)}. Fill in its labels, then run this command again.`); - } - let other = null; - if (second) { - const { role, spec, label } = second; - const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); - other = { role, label, ...check(tools, readModel(spec, cfg), out, expected) }; - } - const drawn = await draw({ now, other, map, out, title: flags.title }); - return finish(out, drawn, rulesAsExpected(now, other, expected), flags.png); + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); + throw new CannotFinish( + `\nWrote ${rel(map)}. Fill in its labels, then run this command again.`, + ); + } + const other = second && { + role: second.role, + label: second.label, + ...check(tools, second.model, work, expected), + }; + const drawn = await draw({ now, other, map, work, out, title: flags.title }); + return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), flags.png); + }); } async function fromChart(input, { "expect-broken": expected, ...flags }) { @@ -573,36 +703,36 @@ async function fromChart(input, { "expect-broken": expected, ...flags }) { if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`); const spec = path.join(path.dirname(input), `${name}.tla`); + const map = spec.replace(/\.tla$/, ".map.mjs"); + const inputs = snapshot([input]); if (fs.existsSync(spec)) { console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); } else { script("chart-to-tla.mjs", [input, "--module", name], input); console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); } + snapshot([spec, cfgOf(spec), map], inputs); const tools = findTools(); + const model = readInput(spec, expected); const out = outDir(flags.out, input, name); - const now = check(tools, readInput(spec, expected), out, expected); - const drawn = await draw({ - now, - map: spec.replace(/\.tla$/, ".map.mjs"), - out, - title: flags.title, - comparing: true, - }); - const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { - okExits: [0, 1], - }); - if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); - else - console.log( - `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true }); + const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { + okExits: [0, 1], + }); + if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); + else + console.log( + `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + ); + return finish( + { work, out, inputs }, + drawn, + rulesAsExpected(now, null, expected) && compared.status === 0, + flags.png, ); - return finish( - out, - drawn, - rulesAsExpected(now, null, expected) && compared.status === 0, - flags.png, - ); + }); } try { diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 79618ba78..a90c7e4a3 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -1,9 +1,18 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { checkTlcOutput, @@ -741,6 +750,71 @@ test("statechart.mjs writes the spec next to the chart, keeps it on the next run ); }); +test("statechart.mjs writes no output folder when it refuses the input", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT Paid\n"); + writeFileSync(join(dir, "cart.mmd"), "stateDiagram-v2\n [*] --> Cart\n Cart --> Paid : pay\n"); + for (const args of [ + ["from-spec", "Pya.tla"], + ["from-spec", "Pay.tla", "--expect-broken", "Unpaid"], + ["from-spec", "Pay.tla", "--fixed", "PayFixd.tla"], + ["from-chart", "cart.mmd", "--expect-broken", "Unpaid"], + ]) { + const r = node("statechart.mjs", args, dir, { TLA2TOOLS: join(dir, "tla2tools.jar") }); + assert.equal(r.status, 2, r.stderr); + } + assert.deepEqual( + readdirSync(dir).filter((f) => f.endsWith("-statechart")), + [], + ); +}); + +test("statechart.mjs does not use an output folder that has files that it did not write", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + const env = { TLA2TOOLS: join(dir, "tla2tools.jar") }; + const cwd = node("statechart.mjs", ["from-spec", "Pay.tla", "--out", "."], dir, env); + assert.equal(cwd.status, 2); + assert.equal( + cwd.stderr, + ". has 3 files that this command did not write, such as Pay.cfg. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + const out = join(dir, "Pay-statechart"); + mkdirSync(join(out, "tlc"), { recursive: true }); + writeFileSync(join(out, "chart.mmd"), "kept\n"); + writeFileSync(join(out, ".DS_Store"), ""); + writeFileSync(join(out, "notes.md"), "mine\n"); + const notes = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, env); + assert.equal(notes.status, 2); + assert.equal( + notes.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(dir).sort(), [ + "Pay-statechart", + "Pay.cfg", + "Pay.tla", + "tla2tools.jar", + ]); + assert.deepEqual(readdirSync(out).sort(), [".DS_Store", "chart.mmd", "notes.md", "tlc"]); + assert.equal(readFileSync(join(out, "chart.mmd"), "utf8"), "kept\n"); +}); + +test("statechart.mjs exits 2 and names a helper script that cannot start", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + writeFileSync(join(dir, "no-node.cjs"), 'process.execPath = "/nonexistent/node";\n'); + const r = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, { + NODE_OPTIONS: `--require ${join(dir, "no-node.cjs")}`, + }); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /chart-to-tla\.mjs/); +}); + test("statechart.mjs names the chart it refuses and the argument it does not know", () => { const dir = mkdtempSync(join(tmpdir(), "statechart-")); writeFileSync( @@ -1255,14 +1329,7 @@ test( scaffold, /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, ); - assert.equal( - readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), - "SPECIFICATION Spec\n", - ); - assert.equal( - readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), - "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", - ); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); writeFileSync( join(dir, "Pay.map.mjs"), @@ -1305,6 +1372,14 @@ Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, "summary.json", "tlc", ]); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), + "SPECIFICATION Spec\n", + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", + ); }, ); @@ -1638,6 +1713,10 @@ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. holds.stdout, /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/, ); + const listed = run("--expect-broken", "NoFalseSaved,TypeOK"); + assert.equal(listed.status, 1, listed.stderr); + assert.match(listed.stdout, /\nExpected to break but holds: TypeOK\.\n/); + assert.doesNotMatch(listed.stdout, /Broken but not expected/); writeFileSync( join(dir, "code.map.mjs"), @@ -2280,3 +2359,432 @@ Spec == \\* not [][Inc]_x assert.equal(named.status, 0, named.stderr); assert.equal(named.stderr, ""); }); + +const writeLamp = (dir) => { + writeFileSync( + join(dir, "Lamp.tla"), + "---- MODULE Lamp ----\nVARIABLE on\nInit == on = FALSE\nOn == on' = TRUE\nOff == on' = FALSE\nSpec == Init /\\ [][On \\/ Off]_on\n====\n", + ); + writeFileSync(join(dir, "Lamp.cfg"), "SPECIFICATION Spec\n"); + writeFileSync( + join(dir, "Lamp.map.mjs"), + 'export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "you flip the switch";\n', + ); + return join(dir, "Lamp-statechart"); +}; + +test("a rerun without --png removes the chart.png of an earlier run", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeLamp(dir); + mkdirSync(out); + writeFileSync(join(out, "chart.png"), ""); + const r = node("statechart.mjs", ["from-spec", "Lamp.tla"], dir); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + readdirSync(out).filter((f) => f.endsWith(".png")), + [], + ); +}); + +const writeSlowPay = (dir, ticks, paid = "Paid") => { + writeFileSync( + join(dir, "Pay.tla"), + `---- MODULE Pay ---- +EXTENDS Naturals +VARIABLES screen, charges, ticks +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ ticks = 0 +TapPay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED <> +Charge == screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED <> +Done == screen = "Paying" /\\ charges > 0 /\\ screen' = "${paid}" /\\ UNCHANGED <> +Tick == ticks < ${ticks} /\\ ticks' = ticks + 1 /\\ UNCHANGED <> +Spec == Init /\\ [][TapPay \\/ Charge \\/ Done \\/ Tick]_vars +ChargedAtMostTwice == charges <= 2 +==== +`, + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT ChargedAtMostTwice\n"); + writeFileSync( + join(dir, "Pay.map.mjs"), + 'export const state = (v) => v.screen;\nexport const step = (a) => ({ TapPay: "you tap Pay", Charge: "the charge goes through", Done: "the receipt shows" })[a] ?? "";\n', + ); + return join(dir, "Pay-statechart"); +}; +const startRun = (dir, { wrap = [], env = {}, args = ["from-spec", "Pay.tla"] } = {}) => { + const [command, ...rest] = [...wrap, process.execPath, join(scripts, "statechart.mjs"), ...args]; + const child = spawn(command, rest, { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "ignore", "pipe"], + detached: true, + }); + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += d; + }); + return { + child, + done: new Promise((done) => child.on("close", (status) => done({ status, stderr }))), + }; +}; +const until = async (ready) => { + while (!ready()) await sleep(20); +}; +const ownTmp = (dir, name) => { + const tmp = join(dir, name); + mkdirSync(tmp); + return tmp; +}; +const tlcStarted = (tmp) => readdirSync(tmp).some((f) => f.startsWith("tlc-")); +const staged = (dir) => readdirSync(dir).filter((f) => f.startsWith(".")); + +test( + "a run whose Node is killed mid-TLC leaves the output folder as it was, and a rerun charts its own spec", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 60000); + const tmp = ownTmp(dir, "tmp"); + const first = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(300); + first.child.kill("SIGTERM"); + await first.done; + assert.doesNotThrow(() => process.kill(-first.child.pid, 0), "TLC outlives its Node"); + assert.equal(existsSync(out), false); + + writeSlowPay(dir, 200, "Receipt"); + const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + process.kill(-first.child.pid, "SIGKILL"); + assert.equal(second.status, 0, second.stderr); + for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) { + const text = readFileSync(join(out, file), "utf8"); + assert.match(text, /Receipt/); + assert.doesNotMatch(text, /Paid/); + } + assert.deepEqual(staged(dir), []); + }, +); + +const changedMidRun = async ({ wrap = [], env = {} }) => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp-a"); + const first = startRun(dir, { wrap, env: { ...env, TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(500); + writeSlowPay(dir, 20000, "Receipt"); + const second = startRun(dir, { wrap, env: { ...env, TMPDIR: ownTmp(dir, "tmp-b") } }); + const [paid, receipt] = await Promise.all([first.done, second.done]); + assert.equal(receipt.status, 0, receipt.stderr); + assert.equal(paid.status, 2, paid.stderr); + assert.equal( + paid.stderr, + "Pay.tla changed while this run read it, so the run left Pay-statechart as it was. Run the command again.\n", + ); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /Receipt/); + assert.doesNotMatch(chart, /Paid/); + assert.deepEqual(staged(dir), []); +}; + +test( + "a run whose spec changes while it reads it leaves the output folder, and the run that read the new spec charts it", + needsTlc, + () => changedMidRun({}), +); + +const unshare = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"]; +const hidePidNamespace = `#define _GNU_SOURCE +#include +#include +#include +#include +ssize_t readlink(const char *path, char *buf, size_t size) { + static ssize_t (*real)(const char *, char *, size_t); + if (!real) real = dlsym(RTLD_NEXT, "readlink"); + if (path && strcmp(path, "/proc/self/ns/pid") == 0) { errno = EIO; return -1; } + return real(path, buf, size); +} +`; +const hidingPreload = (() => { + if (spawnSync(unshare[0], [...unshare.slice(1), "true"]).status !== 0) return null; + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "hide.c"), hidePidNamespace); + const so = join(dir, "hide.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "hide.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "runs in PID namespaces that hide which namespace they are in chart only their own spec", + { + skip: + needsTlc.skip || + (!hidingPreload && "cannot make a PID namespace, or cannot compile a readlink preload"), + }, + () => changedMidRun({ wrap: unshare, env: { LD_PRELOAD: hidingPreload } }), +); + +test( + "a file that is put in the output folder during a run stays, and the run exits 2", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp"); + const run = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + mkdirSync(out); + writeFileSync(join(out, "notes.md"), "mine\n"); + const r = await run.done; + assert.equal(r.status, 2); + assert.equal( + r.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(out), ["notes.md"]); + assert.deepEqual(staged(dir), []); + }, +); + +const failRenames = `#define _GNU_SOURCE +#include +#include +#include +#include +static size_t hits; +int rename(const char *from, const char *to) { + static int (*real)(const char *, const char *); + if (!real) real = dlsym(RTLD_NEXT, "rename"); + const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS"); + base = base ? base + 1 : to; + if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) { + errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO; + return -1; + } + return real(from, to); +} +`; +const failingRenames = (() => { + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "rename.c"), failRenames); + const so = join(dir, "rename.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "rename.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "a run whose rename fails after it moves the output folder aside puts the folder back, or says where it is", + { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") }, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeLamp(dir); + const args = ["from-spec", "Lamp.tla", "--out", "shared"]; + const shared = join(dir, "shared"); + const first = node("statechart.mjs", args, dir); + assert.equal(first.status, 0, first.stderr); + const chart = readFileSync(join(shared, "chart.mmd"), "utf8"); + const run = (fails) => + node("statechart.mjs", args, dir, { LD_PRELOAD: failingRenames, RENAME_FAILS: fails }); + + const back = run("NI"); + assert.equal(back.status, 2, back.stderr); + assert.match( + back.stderr, + /^cannot replace shared: EIO: .*\. The run left shared as it was\.\n$/, + ); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), []); + + const stuck = run("NII"); + assert.equal(stuck.status, 2, stuck.stderr); + const named = stuck.stderr.match( + /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\.\n$/, + ); + assert.ok(named, stuck.stderr); + const aside = named[1]; + assert.equal(existsSync(shared), false); + assert.equal(readFileSync(join(dir, aside, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), [aside]); + }, +); + +test( + "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeSlowPay(dir, 0); + const tmp = ownTmp(dir, "tmp"); + const log = join(dir, "java.log"); + const java = join(dir, "java"); + writeFileSync( + java, + `#!/bin/sh +for a; do case "$a" in -Djava.io.tmpdir=*) t="\${a#-Djava.io.tmpdir=}";; esac; done +"$REAL_JAVA" "$@" > "$LOG.$$"; s=$? +cat "$LOG.$$" +echo "$t: $(grep 'Naturals.tla' "$LOG.$$")" >> "$LOG" +rm "$LOG.$$" +exit $s +`, + { mode: 0o755 }, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { + JAVA: java, + TLA2TOOLS: tools.jar, + REAL_JAVA: tools.java, + LOG: log, + TMPDIR: tmp, + }); + assert.equal(r.status, 0, r.stderr); + const runs = readFileSync(log, "utf8").trim().split("\n"); + assert.ok(runs.length > 0); + const folders = new Set(); + for (const line of runs) { + const [folder, parsed] = line.split(": "); + assert.ok(folder.startsWith(join(tmp, "tlc-")), line); + assert.equal(parsed, `Parsing file ${join(folder, "Naturals.tla")}`); + folders.add(folder); + } + assert.equal(folders.size, runs.length); + }, +); + +test( + "a run that TLC stops on an error keeps TLC's output and writes no output folder", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + "---- MODULE Pay ----\nVARIABLE x\nASSUME FALSE\nInit == x = 0\nSpec == Init /\\ [][x' = x]_x\n====\n", + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Pay.map.mjs"), 'export const state = () => "Cart";\n'); + const tmp = ownTmp(dir, "tmp"); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { TMPDIR: tmp }); + assert.equal(r.status, 2); + const [, file] = r.stderr.match( + /^(\S+\/tlc\/Pay\.out): TLC stopped on an error that is not a broken rule: Assumption .* is false\.\n$/, + ); + assert.ok(file.startsWith(tmp), file); + assert.match(readFileSync(file, "utf8"), /Assumption .* is false/); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); + }, +); + +test( + "runs of two specs that overlap on one output folder all finish, and the folder always holds one run's files", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 400); + writeLamp(dir); + const pay = ["from-spec", "Pay.tla", "--out", "shared"]; + const lamp = ["from-spec", "Lamp.tla", "--out", "shared"]; + const alone = {}; + for (const args of [pay, lamp]) { + const solo = join(dir, `${args[1]}-alone`); + const r = node("statechart.mjs", [...args.slice(0, 2), "--out", solo], dir); + assert.equal(r.status, 0, r.stderr); + alone[args[1]] = { + top: readdirSync(solo).sort().join(), + tlc: readdirSync(join(solo, "tlc")).sort().join(), + chart: readFileSync(join(solo, "chart.mmd"), "utf8"), + summary: readFileSync(join(solo, "summary.json"), "utf8"), + }; + } + const shared = join(dir, "shared"); + const seen = new Set(); + let watching = true; + const watch = (async () => { + while (watching) { + try { + seen.add(readdirSync(join(shared, "tlc")).sort().join()); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + await sleep(5); + } + })(); + const runs = []; + for (let i = 0; i < 6; i++) { + runs.push(startRun(dir, { args: i % 2 ? lamp : pay }).done); + await sleep(150); + } + const finished = await Promise.all(runs); + watching = false; + await watch; + for (const r of finished) assert.equal(r.status, 0, r.stderr); + const runsOf = new Map(Object.entries(alone).map(([spec, a]) => [a.tlc, spec])); + for (const listing of seen) assert.ok(runsOf.has(listing), `tlc/ had ${listing}`); + const last = alone[runsOf.get(readdirSync(join(shared, "tlc")).sort().join())]; + assert.equal(readdirSync(shared).sort().join(), last.top); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), last.chart); + assert.equal(readFileSync(join(shared, "summary.json"), "utf8"), last.summary); + assert.equal(existsSync(out), false); + assert.deepEqual(staged(dir), []); + }, +); + +test( + "the output folder model keeps every rule, and breaks one with a shared TLC folder, without each guard, or when a failed rename deletes the folder moved aside", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const model = (ext) => readFileSync(join(scripts, "model", `OutputFolder${ext}`), "utf8"); + const r = node( + "statechart.mjs", + ["from-spec", join(scripts, "model", "OutputFolder.tla"), "--out", join(dir, "out")], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nNo rule is broken\.\n/); + for (const [i, [rule, guard, without, story]] of [ + [ + "ChartsOneSpec", + "/\\ read' = Set(read, r, read[r] \\cup {spec})", + "/\\ read' = [q \\in Runs |-> read[q] \\cup {spec}]", + /You start run 2 in a second shell\..*\n.*Run 2 moves its folder into place\. Now: Two runs going: chart of both specs/, + ], + [ + "ChartsOneSpec", + '/\\ pc[r] = "publish" /\\ spec = snap[r] /\\ ~out.notes', + '/\\ pc[r] = "publish" /\\ ~out.notes', + /You edit the spec\..*\n.*Run 1 moves its folder into place\. Now: One run going: chart of both specs/, + ], + [ + "KeepsYourFiles", + '/\\ pc[r] = "aside" /\\ ~aside[r].notes\n', + '/\\ pc[r] = "aside"\n', + /Run 1 deletes the old folder and finishes\. Now: Done: chart of the spec\n/, + ], + [ + "NeverLosesChart", + " /\\ \\/ /\\ aside[r].exists /\\ ~out.exists\n /\\ out' = aside[r]\n /\\ aside' = Set(aside, r, NoFolder)\n \\/ UNCHANGED <>\n", + " /\\ out' = out\n /\\ aside' = Set(aside, r, IF aside[r].notes THEN aside[r] ELSE NoFolder)\n", + /Run 2 moves the old folder aside\. Now: .*, the old chart moved aside\n.*Run 2 cannot rename a folder and stops\. Now: One run going: no folder\n/, + ], + [ + "NeverLosesChart", + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists /\\ ~out.notes', + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists', + /You save notes in the folder\..*\n.*Run 2 finds another run's folder in place and deletes the old folder\. Now: Two runs going: your notes\n/, + ], + ].entries()) { + const mutant = join(dir, `${i}-${rule}`); + mkdirSync(mutant); + assert.ok(model(".tla").includes(guard), guard); + writeFileSync(join(mutant, "OutputFolder.tla"), model(".tla").replace(guard, without)); + for (const ext of [".cfg", ".map.mjs"]) + writeFileSync(join(mutant, `OutputFolder${ext}`), model(ext)); + const m = node( + "statechart.mjs", + ["from-spec", "OutputFolder.tla", "--expect-broken", rule], + mutant, + ); + assert.equal(m.status, 0, m.stdout + m.stderr); + assert.match(m.stdout, story); + } + }, +); diff --git a/pstack/skills/statechart/scripts/tlc-to-chart.mjs b/pstack/skills/statechart/scripts/tlc-to-chart.mjs index a933666d0..ccb68fd7b 100644 --- a/pstack/skills/statechart/scripts/tlc-to-chart.mjs +++ b/pstack/skills/statechart/scripts/tlc-to-chart.mjs @@ -228,6 +228,7 @@ if (typeof mapping.state !== "function") if (args.view !== "customer" && typeof mapping.detail !== "function") throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); const SEP = "\u0000"; +const edgeId = (from, to) => `${from}${SEP}${SEP}${to}`; const rules = new Map(); for (const f of args.specs) @@ -263,7 +264,6 @@ function stepGuard(action, before, after) { .replace(/[[\]]/g, "") .trim(); } -// A label shows guard words only when every model step behind it has some. function guarded(guards, label) { const words = guards?.get(label); return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label; @@ -273,9 +273,6 @@ const addGuard = (guards, label, words) => { guards.get(label).add(words); }; -// ---------- projection between two models ---------- -// Paths of record fields that a has and b does not. It looks inside records only: sets and -// sequences can be empty in a start state, so they show no fields. function lacking(a, b, prefix = []) { return Object.keys(a).flatMap((k) => !(k in b) @@ -305,8 +302,6 @@ function fill(vars, paths, from) { return out; } -// One model: its graph, the facts of each state, and its reader keys. -// lookup turns a state as the chart sees it back into the model's own state. function modelOf(graph, lookup = (vars) => vars) { const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); @@ -335,7 +330,7 @@ function quotient(m) { const from = keys.get(e.from); const to = keys.get(e.to); if (from === to) continue; - const id = `${from}${SEP}${SEP}${to}`; + const id = edgeId(from, to); if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() }); const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars]; const label = stepLabel(e.action, before, after); @@ -343,7 +338,6 @@ function quotient(m) { edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); } - // Most common wording first. for (const e of edges.values()) e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); const settled = new Set( @@ -359,24 +353,21 @@ const mainInit = [...graph.nodes.values()].find((n) => n.init).vars; const secondDot = args.fixedDot ?? args.againstDot; const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null; -let lacks = []; -let extra = []; -let second = null; -let secondQ = null; -if (secondGraph) { - const secondInit = [...secondGraph.nodes.values()].find((n) => n.init).vars; - lacks = lacking(mainInit, secondInit); - extra = lacking(secondInit, mainInit); - for (const n of secondGraph.nodes.values()) { - n.raw = n.vars; - n.vars = fill(n.raw, lacks, mainInit); - } - second = modelOf(secondGraph, (vars) => omit(vars, lacks)); - secondQ = quotient(second); -} +const secondInit = secondGraph && [...secondGraph.nodes.values()].find((n) => n.init).vars; +const lacks = secondGraph ? lacking(mainInit, secondInit) : []; +const extra = secondGraph ? lacking(secondInit, mainInit) : []; +const secondFilled = secondGraph && { + ...secondGraph, + nodes: new Map( + [...secondGraph.nodes].map(([id, n]) => [ + id, + { ...n, raw: n.vars, vars: fill(n.vars, lacks, mainInit) }, + ]), + ), +}; +const second = secondFilled && modelOf(secondFilled, (vars) => omit(vars, lacks)); +const secondQ = second && quotient(second); -// Reader path of one trace: drop system steps that keep the reader in the same state. -// Reader actions stay even then, because the reader did them. function readerPath(trace, key, labelOf = stepLabel) { const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; for (let i = 1; i < trace.steps.length; i++) { @@ -392,24 +383,19 @@ function readerPath(trace, key, labelOf = stepLabel) { } const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer"); -// Replay a bug on the second model, step by step. The reader sees the screens and step words of its -// reader path. The reader does not see a step that keeps the screen and that they do not take, so -// the second model must keep the screen at that step too, or be unable to take it. Returns the -// first step that the second model shows the reader differently, or, when it shows them all the -// same way, whether a state on the way breaks the same rule there. function replaySecond(trace) { if (!second) return null; const shared = (vars) => stateKey(omit(vars, lacks)); const sharedOf = (n) => stateKey(omit(n.raw, extra)); const out = new Map(); - for (const e of [...secondGraph.edges, ...secondGraph.loops]) { + for (const e of [...second.graph.edges, ...second.graph.loops]) { if (!out.has(e.from)) out.set(e.from, []); out.get(e.from).push(e); } - const actions = new Set([...secondGraph.edges, ...secondGraph.loops].map((e) => e.action)); - const nodeOf = (id) => secondGraph.nodes.get(id); + const actions = new Set([...second.graph.edges, ...second.graph.loops].map((e) => e.action)); + const nodeOf = (id) => second.graph.nodes.get(id); const screen = new Map( - [...secondGraph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), + [...second.graph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), ); const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action; const settle = (ids) => { @@ -422,7 +408,7 @@ function replaySecond(trace) { const seen = readerPath(trace, main.customer, readerLabel); const shownAt = new Map(seen.map((p, j) => [p.modelStep, j])); let here = settle( - [...secondGraph.nodes.values()] + [...second.graph.nodes.values()] .filter((n) => n.init && screen.get(n.id) === seen[0].key) .map((n) => n.id), ); @@ -491,7 +477,6 @@ function readTraces(files, paths = []) { }); } // With -continue, TLC reports every bad state, including states after the first bad one. -// Cut each trace at its first bad state so one bug counts once. function firstBad(raw) { const badStates = new Map(); for (const t of raw) { @@ -515,8 +500,6 @@ const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; const rawSecondTraces = readTraces(secondTraceFiles, lacks); const traces = firstBad(rawTraces); const secondTraces = firstBad(rawSecondTraces); -// Classes and their shortest traces come from the customer states in every view, -// so class N is the same bug and the same trace in the customer and developer charts. function group(cuts, m) { const byId = new Map(); for (const trace of cuts) { @@ -546,38 +529,44 @@ function group(cuts, m) { } return byId; } -// Fewest reader steps first, then fewest model steps, then rule name, then cause (or end state). const byOrder = (a, b) => a.steps - b.steps || a.trace.steps.length - b.trace.steps.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); -const classesById = group(traces, main); -const classes = [...classesById.values()].sort(byOrder); -for (const c of classes) { - c.path = readerPath(c.trace, main.key); - c.end = c.path[c.path.length - 1].key; - c.second = replaySecond(c.trace); +const inSecond = (l) => `${within} ${L}, ${l}`; +const modelEdges = new Map(now.edges); +if (secondQ) + for (const [id, e] of secondQ.edges) + if (!modelEdges.has(id)) + modelEdges.set(id, { + ...e, + labels: new Set([...e.labels].map(inSecond)), + guards: new Map([...e.guards].map(([l, words]) => [inSecond(l), words])), + secondOnly: true, + }); +function divergence(s) { + if (s?.key === undefined) return s; + const plain = edgeId(s.from, s.key); + const text = inSecond(`${s.label}${s.disabled ? " cannot happen" : ""}`); + const drawn = !s.disabled && [s.label, text].some((l) => modelEdges.get(plain)?.labels.has(l)); + return { ...s, text, edge: drawn ? plain : `${plain}${SEP}second` }; } -// A bug of the second model that no current bug matches numbers after the current bugs. +const withPath = (c, m) => { + const path = readerPath(c.trace, m.key); + return { ...c, path, end: path[path.length - 1].key }; +}; +const classesById = group(traces, main); +const mainClasses = [...classesById.values()] + .sort(byOrder) + .map((c) => ({ ...withPath(c, main), second: divergence(replaySecond(c.trace)) })); const introduced = second - ? [...group(secondTraces, second).values()].filter((c) => !classesById.has(c.id)).sort(byOrder) + ? [...group(secondTraces, second).values()] + .filter((c) => !classesById.has(c.id)) + .sort(byOrder) + .map((c) => ({ ...withPath(c, second), second: null, introduced: true })) : []; -for (const c of introduced) { - c.path = readerPath(c.trace, second.key); - c.end = c.path[c.path.length - 1].key; - c.second = null; - c.introduced = true; -} -classes.push(...introduced); +const classes = [...mainClasses, ...introduced]; -// ---------- status ---------- -// A chart state is bad when it holds a state where a rule breaks, or when every path from the start -// to it passes such a state: what the reader sees after a bug is part of that bug. TLC stops each -// path at the first state that breaks the rule, so a later state that breaks it again does not -// color its chart state when a path without a bug also reaches that chart state. -// With a fix, a state is still wrong when it is bad in the fixed model, and fixed when it is bad only -// in the current model; without --fixed-traces every bad state is wrong. -// Against a spec, a state is wrong when it is bad in either model, and green when only the spec has it. const diverges = (c) => c.second?.key !== undefined; function badKeys(m, keys, raw) { const g = m.graph; @@ -610,6 +599,7 @@ const badSecond = ? badNow : new Set(); const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); +const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); const onlySecond = (k) => second && !now.nodes.has(k); const status = (k) => { if (role === "against") @@ -619,60 +609,39 @@ const status = (k) => { return badNow.has(k) ? "wrong" : "works"; }; -const edges = new Map(now.edges); -if (secondQ) { - const only = (l) => `${within} ${L}, ${l}`; - for (const [id, e] of secondQ.edges) { - if (!edges.has(id)) - edges.set(id, { - ...e, - labels: new Set([...e.labels].map(only)), - guards: new Map([...e.guards].map(([l, words]) => [only(l), words])), - secondOnly: true, - }); - } -} -// The second model shows the reader one step of the bug differently. Its edge leaves the chart state -// before that step and ends where the second model goes instead, or stays put when the second model -// cannot take the step. A step that the chart already draws there, in the same words, adds no edge. -for (const c of classes) { - if (!diverges(c)) continue; - const s = c.second; - const plain = `${s.from}${SEP}${SEP}${s.key}`; - s.text = `${within} ${L}, ${s.label}${s.disabled ? " cannot happen" : ""}`; - if (!s.disabled && [s.label, s.text].some((l) => edges.get(plain)?.labels.has(l))) { - s.edge = plain; - continue; - } - s.edge = `${plain}${SEP}second`; - if (!edges.has(s.edge)) - edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); - edges.get(s.edge).labels.add(s.text); -} - -// ---------- highlighted path ---------- const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; if (args.highlight > 0 && classes.length > 0 && !chosen) throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); -const pathNumbers = new Map(); -if (chosen) { - chosen.path.slice(1).forEach((p, i) => { - const id = `${chosen.path[i].key}${SEP}${SEP}${p.key}`; - if (!pathNumbers.has(id)) pathNumbers.set(id, []); - pathNumbers - .get(id) - .push({ n: i + 1, label: p.label, shown: p.guard ? `${p.label} [${p.guard}]` : p.label }); - if (!edges.has(id)) - edges.set(id, { from: chosen.path[i].key, to: p.key, labels: new Set([p.label]) }); +if (chosen && diverges(chosen)) onPath.add(chosen.second.key); + +const edgesAlong = (c) => + c.path.slice(1).map((p, i) => ({ + id: edgeId(c.path[i].key, p.key), + from: c.path[i].key, + to: p.key, + label: p.label, + guard: p.guard, + })); +function chartEdges() { + const edges = new Map(modelEdges); + for (const { second: s } of classes.filter(diverges)) { + if (modelEdges.has(s.edge)) continue; + if (!edges.has(s.edge)) + edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); + edges.get(s.edge).labels.add(s.text); + } + const pathNumbers = new Map(); + (chosen ? edgesAlong(chosen) : []).forEach((step, i) => { + if (!pathNumbers.has(step.id)) pathNumbers.set(step.id, []); + const shown = step.guard ? `${step.label} [${step.guard}]` : step.label; + pathNumbers.get(step.id).push({ n: i + 1, label: step.label, shown }); + if (!edges.has(step.id)) + edges.set(step.id, { from: step.from, to: step.to, labels: new Set([step.label]) }); }); - if (diverges(chosen)) onPath.add(chosen.second.key); -} + if (!args.cover) return { edges, pathNumbers, coverEdges: null }; -// ---------- cover: shortest path to each settled reader state ---------- -let coverEdges = null; -if (args.cover) { - const keepEdges = new Set(); + const coverEdges = new Set(); const bfs = (q, targets) => { const parent = new Map([...q.initial].map((k) => [k, null])); const queue = [...q.initial]; @@ -690,7 +659,7 @@ if (args.cover) { } } for (const t of targets) { - for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) keepEdges.add(id); + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) coverEdges.add(id); } }; bfs(now, now.settled); @@ -701,23 +670,21 @@ if (args.cover) { ); const pathLabels = new Map(); for (const c of classes) { - c.path.slice(1).forEach((p, i) => { - const id = `${c.path[i].key}${SEP}${SEP}${p.key}`; - keepEdges.add(id); - pathLabels.set(id, (pathLabels.get(id) ?? new Set()).add(p.label)); - }); - if (diverges(c)) keepEdges.add(c.second.edge); - } - for (const id of keepEdges) { - if (!edges.has(id)) { - const [from, to] = id.split(`${SEP}${SEP}`); - edges.set(id, { from, to, labels: pathLabels.get(id) ?? new Set() }); + for (const step of edgesAlong(c)) { + coverEdges.add(step.id); + pathLabels.set(step.id, (pathLabels.get(step.id) ?? new Set()).add(step.label)); } + if (diverges(c)) coverEdges.add(c.second.edge); } - coverEdges = keepEdges; + for (const id of coverEdges) { + if (edges.has(id)) continue; + const [from, to] = id.split(`${SEP}${SEP}`); + edges.set(id, { from, to, labels: pathLabels.get(id) }); + } + return { edges, pathNumbers, coverEdges }; } +const { edges, pathNumbers, coverEdges } = chartEdges(); -// ---------- Mermaid ---------- const LEGEND = { works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")], wrong: [ @@ -730,14 +697,10 @@ const LEGEND = { ], }; -// ---------- groups ---------- -// The mapping's group() names the group of each model state, and a chart state takes the group -// that all its model states share. The fill shows the group. The status from TLC stays a border on -// top, so no group hides a broken rule or the second model's states. const grouping = typeof mapping.group === "function"; const groupsOf = new Map(); const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : ""); -let groupNames = []; +const groupNames = []; const fills = new Map(); if (grouping) { const add = (k, vars, m) => { @@ -749,7 +712,7 @@ if (grouping) { }; for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); if (second) - for (const n of secondGraph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); + for (const n of second.graph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); const given = mapping.groupColors ?? {}; if (!isRecord(given)) @@ -770,10 +733,10 @@ if (grouping) { throw new Error( `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`, ); - groupNames = [ + groupNames.push( ...Object.keys(given).filter((g) => found.has(g)), ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(), - ]; + ); for (const [g, color] of Object.entries(given)) { if (color === undefined || color === null || color === "") continue; const why = groupColorProblem(color); @@ -818,7 +781,6 @@ const frontMatter = (title) => [ " useMaxWidth: false", "---", ]; -// drawn holds { group, status, cls } for each state the chart draws. function head(drawn) { const out = [...frontMatter(args.title), "stateDiagram-v2"]; if (role) out.push(` %% second model (${role}): ${L}`); @@ -834,11 +796,11 @@ function head(drawn) { ] : used.map((s) => [...LEGEND[s], s]); if (grouping) { - const classes = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); + const styleClasses = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])]; for (const [base, fill] of bases) for (const border of ["", "Wrong", "Fixed", "Path"]) - if (classes.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); + if (styleClasses.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); } else for (const c of STATUS_CLASSES) out.push(` ${c}`); if (legend.length) { out.push(` state "Legend" as ${LEGEND_ID} {`); @@ -849,20 +811,14 @@ function head(drawn) { return out; } -// ---------- parallel regions ---------- -// Splits each "context: progress" label into a context region and a progress region. A step of -// one region that happens beside only some states of the other gets a guard. The chart is used -// only when it has fewer states than the flat chart, and when, read back, its combined machine -// has exactly the flat chart's states, start states, and labeled steps. -function regionsChart() { +function regionsChart({ states, starts, edges, classes, proofPath, allPath }) { const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim(); const split = (k) => { const at = k.indexOf(": "); return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null; }; - const nodes = [...allNodes].sort(); + const nodes = [...states].sort(); if (!nodes.every(split)) return null; - const starts = [...new Set([...now.initial, ...(secondQ?.initial ?? [])])]; const steps = []; for (const e of edges.values()) { if (e.labels.size === 0) return { why: "a step has no label" }; @@ -890,9 +846,8 @@ function regionsChart() { ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), ]; - if (args.proof) fs.writeFileSync(args.proof, proof.join("\n") + "\n"); + if (proofPath) fs.writeFileSync(proofPath, proof.join("\n") + "\n"); - // Each region state, and the states of the other region that it is part of a state with. const beside = [new Map(), new Map()]; for (const k of nodes) { const p = split(k); @@ -905,8 +860,6 @@ function regionsChart() { if (size >= nodes.length) return { why: `${size} region states are not fewer than the ${nodes.length} flat states` }; - // A region state's status is the lowest status of the states it is part of, and each state must - // then show the higher status of its two region states. const rank = ["works", "fixed", "wrong"]; const regionStatus = [new Map(), new Map()]; for (const k of nodes) { @@ -926,8 +879,6 @@ function regionsChart() { }; } - // A region takes every step it has, so a step that leaves a region state in some states and keeps - // it in others needs a loop there: the loop says the region may also stay. const moves = [new Map(), new Map()]; for (const s of steps) for (const i of [0, 1]) { @@ -951,7 +902,6 @@ function regionsChart() { ) m.delete(id); const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`]))); - // A region state is in a group when every state it is part of is in that group. const regionGroup = [new Map(), new Map()]; for (const k of nodes) { const p = split(k); @@ -963,8 +913,7 @@ function regionsChart() { const drawn = rid.map((m, i) => [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))), ); - // keep holds the ids of the arrows to draw in each region, or is null for every arrow. - const draw = (keep) => { + const draw = (only) => { const out = head(drawn.flat()); out.push(` state "At the same time" as regions {`); let count = 0; @@ -976,13 +925,11 @@ function regionsChart() { for (const s of new Set(starts.map((k) => split(k)[i]))) out.push(`${pad}[*] --> ${rid[i].get(s)}`); for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); - const sorted = [...moves[i]] - .filter(([id]) => !keep || keep[i].has(id)) + const shownMoves = [...moves[i]] + .filter(([id]) => !only || only[i].has(id)) .map(([, m]) => m) .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label)); - for (const m of sorted) { - // The guard names the shorter of the states the step happens beside, or those it does not. - // The mapping's guard words, when it has them, replace those names in the label. + for (const m of shownMoves) { const all = beside[i].get(m.from); const not = [...all].filter((s) => !m.beside.has(s)).sort(); const yes = [...m.beside].sort(); @@ -1016,13 +963,8 @@ function regionsChart() { why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`, }; } - if (args.regionsAll) fs.writeFileSync(args.regionsAll, all.mermaid); + if (allPath) fs.writeFileSync(allPath, all.mermaid); - // The overview keeps each bug path, then a way from the start to each region state and one step - // with each label, as the arrows of the regions that each step moves. It adds the cheapest of - // those next: the fewest new arrows, then the fewest steps. A region with fewer arrows can stay - // where the full chart moves it, so each step that the kept arrows make and TLC's graph does - // not have gets back every arrow that the full chart takes there. const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)])); const key = (from, label, to) => `${from} --> ${to} : ${label}`; const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to)))); @@ -1051,10 +993,10 @@ function regionsChart() { })); }; for (const c of classes) { - c.path.slice(1).forEach((p, j) => { - const e = edges.get(`${c.path[j].key}${SEP}${SEP}${p.key}`); - if (e) stepsOf(e, norm(p.label)).forEach(want); - }); + for (const step of edgesAlong(c)) { + const e = edges.get(step.id); + if (e) stepsOf(e, norm(step.label)).forEach(want); + } if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want); } const cheapest = () => { @@ -1113,14 +1055,14 @@ function regionsChart() { for (;;) { scoped = draw(keep); made = productOf(parseMermaid(scoped.mermaid).regions); - const drawn = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); + const madeKeys = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); const redo = [ ...made.transitions .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to))) .map((t) => [parts.get(t.from), t.label]), ...wanted .filter((s) => made.states.has(bar(s.from))) - .filter((s) => !drawn.has(key(bar(s.from), s.label, bar(s.to)))) + .filter((s) => !madeKeys.has(key(bar(s.from), s.label, bar(s.to)))) .map((s) => [s.from, s.label]), ]; const before = keep[0].size + keep[1].size; @@ -1147,26 +1089,30 @@ function regionsChart() { groups: groupCounts(drawn.flat()), }; } -const regions = args.regions ? regionsChart() : null; +const regions = args.regions + ? regionsChart({ + states: allNodes, + starts: [...initial], + edges, + classes, + proofPath: args.proof, + allPath: args.regionsAll, + }) + : null; if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`); -let keep = allNodes; -if (args.pathOnly && chosen) keep = onPath; -else if (coverEdges) { - keep = new Set([...now.initial, ...(secondQ?.initial ?? [])]); - for (const id of coverEdges) keep.add(edges.get(id).from).add(edges.get(id).to); -} -const ids = new Map(); -let counter = 0; -const idOf = (k) => { - if (!ids.has(k)) ids.set(k, `s${counter++}`); - return ids.get(k); -}; -const sorted = [...keep].sort(); +const flatStates = + args.pathOnly && chosen + ? onPath + : coverEdges + ? new Set([ + ...initial, + ...[...coverEdges].flatMap((id) => [edges.get(id).from, edges.get(id).to]), + ]) + : allNodes; +const sorted = [...flatStates].sort(); const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k))); -// Boxes: the states of one outer layer (layered view), or, in the customer view, the states whose -// labels read "context: progress" with the same context. A context with one state stays a plain state. const boxes = new Map(); const boxOf = new Map(); const inBox = (k, box) => { @@ -1183,10 +1129,12 @@ else if (args.view === "customer" && !args.flat) { } const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`])); for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); +const declared = [...[...boxes.values()].flat(), ...sorted.filter((k) => !boxOf.has(k))]; +const stateIds = new Map(declared.map((k, i) => [k, `s${i}`])); +const idOf = (k) => stateIds.get(k); const caption = (k) => args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; -// Consecutive steps of one arrow with the same words share a line, as in "2, 3. you look". function numbered(steps) { const runs = []; for (const { n, shown } of steps) { @@ -1197,16 +1145,12 @@ function numbered(steps) { return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`); } const drawnLabels = new Set(); -// Mermaid stacks the loops on a state in one place, so two states get one arrow. A blank line sets -// its numbered steps, the current model's steps, and the second model's steps apart, since a -// wrapped line looks like a new one. The blank line holds a no-break space, because Mermaid drops -// an empty line. const arrows = new Map(); for (const [id, e] of edges) { - if (!keep.has(e.from) || !keep.has(e.to)) continue; + if (!flatStates.has(e.from) || !flatStates.has(e.to)) continue; if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue; if (coverEdges && !coverEdges.has(id)) continue; - const pair = `${e.from}${SEP}${SEP}${e.to}`; + const pair = edgeId(e.from, e.to); if (!arrows.has(pair)) arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] }); const a = arrows.get(pair); @@ -1224,7 +1168,7 @@ for (const [id, e] of edges) { a.labels.push(...all); for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l); } -const lines = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ +const transitions = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ from, to, // ";" ends a Mermaid statement, even inside a transition label. @@ -1240,15 +1184,12 @@ const lines = [...arrows.values()].map(({ from, to, groups, plain, labels }) => plain, labels: labels.sort(), })); -const edgeCount = lines.length; -// One arrow from a box's border stands for the same step from every state in the box. It is drawn -// only when every state in the box has that step, with the same labels, to the same state outside -// the box, so the chart still says exactly what each state does. const fromBox = []; +const folded = new Set(); for (const [box, members] of boxes) { const mine = new Set(members); const byStep = new Map(); - for (const l of lines) { + for (const l of transitions) { if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue; const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`; if (!byStep.has(step)) byStep.set(step, []); @@ -1256,7 +1197,7 @@ for (const [box, members] of boxes) { } for (const same of byStep.values()) { if (new Set(same.map((l) => l.from)).size !== members.length) continue; - for (const l of same) lines.splice(lines.indexOf(l), 1); + for (const l of same) folded.add(l); fromBox.push({ ...same[0], box }); } } @@ -1269,19 +1210,17 @@ for (const [box, members] of boxes) { } for (const k of sorted) if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); -const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); for (const k of sorted) if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) out.push(` [*] --> ${idOf(k)}`); const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`; for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); -for (const l of lines) out.push(arrow(idOf(l.from), l)); +for (const l of transitions) if (!folded.has(l)) out.push(arrow(idOf(l.from), l)); sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); const mermaid = regions?.mermaid ?? out.join("\n") + "\n"; if (args.out) fs.writeFileSync(args.out, mermaid); else process.stdout.write(mermaid); -// ---------- stories and summary ---------- const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; function secondLine(c) { @@ -1329,7 +1268,6 @@ if (args.story) { }); fs.writeFileSync(args.story, lines.join("\n")); } -// Labels a product reader would not say: camelCase, snake_case, calls, and joined PascalCase words. const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/; const codeLike = [ ...new Set([ @@ -1354,14 +1292,14 @@ const summary = { ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), modelStates: graph.nodes.size, modelTransitions: graph.edges.length, - chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : keep.size, - chartTransitions: regions?.mermaid ? regions.transitions : edgeCount, + chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size, + chartTransitions: regions?.mermaid ? regions.transitions : transitions.length, chartStatus: regions?.mermaid ? regions.status : Object.fromEntries( ["works", "wrong", "fixed"].map((s) => [ s, - [...keep].filter((k) => status(k) === s).length, + [...flatStates].filter((k) => status(k) === s).length, ]), ), ...(grouping @@ -1405,5 +1343,5 @@ const drawnAs = regions?.mermaid : ""; console.error( `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` + - `${classes.length - introduced.length} bug(s)${extraBugs}`, + `${mainClasses.length} bug(s)${extraBugs}`, ); From f0bc55126a2d5e06c09109390c3395f203b5b9ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 05:31:03 +0000 Subject: [PATCH 9/9] Drop a comment in the public findTlc that only describes the code Co-authored-by: lauren --- harness/pstack/claude-code/skills/statechart/scripts/lib.mjs | 1 - pstack/skills/statechart/scripts/lib.mjs | 1 - 2 files changed, 2 deletions(-) diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index cf8d03823..cf72d5cd1 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -1,7 +1,6 @@ import fs from "node:fs"; import process from "node:process"; -// tla2tools.jar and the java binary, from TLA2TOOLS and JAVA. Throws where to get TLC when TLA2TOOLS is not set. export function findTlc() { const { TLA2TOOLS, JAVA } = process.env; if (!TLA2TOOLS) diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index cf8d03823..cf72d5cd1 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -1,7 +1,6 @@ import fs from "node:fs"; import process from "node:process"; -// tla2tools.jar and the java binary, from TLA2TOOLS and JAVA. Throws where to get TLC when TLA2TOOLS is not set. export function findTlc() { const { TLA2TOOLS, JAVA } = process.env; if (!TLA2TOOLS)