History

Refresh pstack to 0.15.30 from cursor/plugins

Import the pstack subtree of cursor/plugins commit 9b93e81fb2921b9263d9463c15bafeabfe3903aa. Preserve the standalone repository history and upstream file bytes. Adds statechart support.

Kris Yotam committed 2 weeks agomain

Commit 21774ee1 parent eef436c21 files · +7343 −11Browse files

Unified · Split

21 files
  1. .cursor-plugin/plugin.json +1 −1
  2. README.md +2 −1
  3. docs/guide/02-poteto-mode.md +2 −2
  4. docs/guide/06-verify-and-ship.md +14 −0
  5. docs/guide/10-recipes-and-pitfalls.md +2 −2
  6. plugin.json +1 −1
  7. skills/poteto-help/SKILL.md +2 −1
  8. skills/poteto-help/references/recipes.md +1 −1
  9. skills/sharpen/SKILL.md +11 −2
  10. skills/statechart/SKILL.md +36 −0
  11. skills/statechart/scripts/chart-to-tla.mjs +236 −0
  12. skills/statechart/scripts/compare-charts.mjs +104 −0
  13. skills/statechart/scripts/lib.mjs +928 −0
  14. skills/statechart/scripts/model/OutputFolder.cfg +6 −0
  15. skills/statechart/scripts/model/OutputFolder.map.mjs +106 −0
  16. skills/statechart/scripts/model/OutputFolder.tla +176 −0
  17. skills/statechart/scripts/statechart.mjs +961 −0
  18. skills/statechart/scripts/test/statechart.test.mjs +3291 −0
  19. skills/statechart/scripts/test/sync.mmd +19 −0
  20. skills/statechart/scripts/test/upload.mmd +16 −0
  21. skills/statechart/scripts/tlc-to-chart.mjs +1428 −0
.cursor-plugin/plugin.json+1−1
@@ -1,7 +1,7 @@
1{1{
2 "name": "pstack",2 "name": "pstack",
3 "displayName": "pstack",3 "displayName": "pstack",
4 "version": "0.15.28",4 "version": "0.15.30",
5 "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.",5 "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.",
6 "author": {6 "author": {
7 "name": "Lauren Tan"7 "name": "Lauren Tan"
README.md+2−1
@@ -125,7 +125,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it · 124 unmodified lines
125| [`/poteto-mode`](./skills/poteto-mode/SKILL.md) | default entry point for any non-trivial task. |125| [`/poteto-mode`](./skills/poteto-mode/SKILL.md) | default entry point for any non-trivial task. |
126| [`/poteto-help`](./skills/poteto-help/SKILL.md) | you're new to pstack, or unsure which skill, playbook, or principle fits. finds out what you're trying to do, answers that part, and hands you a prompt to type. runs only when you type `/poteto-help`. |126| [`/poteto-help`](./skills/poteto-help/SKILL.md) | you're new to pstack, or unsure which skill, playbook, or principle fits. finds out what you're trying to do, answers that part, and hands you a prompt to type. runs only when you type `/poteto-help`. |
127| [`/in-your-own-words`](./skills/in-your-own-words/SKILL.md) | you want the agent to play back your goal and the problem before it starts. runs only when you type `/in-your-own-words`. |127| [`/in-your-own-words`](./skills/in-your-own-words/SKILL.md) | you want the agent to play back your goal and the problem before it starts. runs only when you type `/in-your-own-words`. |
128| [`/sharpen`](./skills/sharpen/SKILL.md) | you want a task approached the way a top expert would, aimed at what you're really after, without overspecifying it or deciding your choices for you. |128| [`/sharpen`](./skills/sharpen/SKILL.md) | you want a prompt made deeper, not bigger, before you send it: what a top professional would do that you didn't ask for, the raw material they'd start from, their methods and tools, and an outside check that the result is right. hands back the rewritten prompt and leaves your choices open. doesn't do the task. runs only when you type `/sharpen`. |
129| [`/how`](./skills/how/SKILL.md) | you want a walkthrough of how a subsystem works. |129| [`/how`](./skills/how/SKILL.md) | you want a walkthrough of how a subsystem works. |
130| [`/why`](./skills/why/SKILL.md) | you want to know why something was built this way. discovers available MCPs at run time and queries each evidence category in parallel (source control, issue tracker, long-form docs, real-time chat, infra observability, error tracking, analytics warehouse). |130| [`/why`](./skills/why/SKILL.md) | you want to know why something was built this way. discovers available MCPs at run time and queries each evidence category in parallel (source control, issue tracker, long-form docs, real-time chat, infra observability, error tracking, analytics warehouse). |
131| [`/recall`](./skills/recall/SKILL.md) | you're starting or resuming work and want your recent context on a topic rebuilt from your own chat history and the shared record, handed back as a tight current-state brief. |131| [`/recall`](./skills/recall/SKILL.md) | you're starting or resuming work and want your recent context on a topic rebuilt from your own chat history and the shared record, handed back as a tight current-state brief. |
@@ -151,6 +151,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it · 19 unmodified lines
151| [`/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. |151| [`/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. |
152| [`/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. |152| [`/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. |
153| [`/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. |153| [`/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. |
154| [`/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. |
154| [`/unslop`](./skills/unslop/SKILL.md) | you're cleaning up writing. removes AI tells. |155| [`/unslop`](./skills/unslop/SKILL.md) | you're cleaning up writing. removes AI tells. |
155| [`/bro`](./skills/bro/SKILL.md) | you want the last message restated in plain human language, no jargon. |156| [`/bro`](./skills/bro/SKILL.md) | you want the last message restated in plain human language, no jargon. |
156| [`/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. |157| [`/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. |
docs/guide/02-poteto-mode.md+2−2
@@ -66,13 +66,13 @@ For a noisy report, such as a long thread or a vague bug, make the restatement t · 65 unmodified lines
66 66
67A misreading shows up in the restatement, before any code exists. Correct it there, and it costs you one message instead of one wrong fix. For the restatement alone, with no playbook, run [`/in-your-own-words`](../../skills/in-your-own-words/SKILL.md). It restates what the agent thinks your goals are and what problem you're trying to solve.67A misreading shows up in the restatement, before any code exists. Correct it there, and it costs you one message instead of one wrong fix. For the restatement alone, with no playbook, run [`/in-your-own-words`](../../skills/in-your-own-words/SKILL.md). It restates what the agent thinks your goals are and what problem you're trying to solve.
68 68
69When you're unsure whether a task is aimed right, put [`/sharpen`](../../skills/sharpen/SKILL.md) in front of it:69When you're unsure whether a prompt is aimed right, run [`/sharpen`](../../skills/sharpen/SKILL.md) on it before you send it:
70 70
71```text71```text
72/sharpen write a migration guide for the v2 config format.72/sharpen write a migration guide for the v2 config format.
73```73```
74 74
75Before it starts, the agent asks how a top expert would do the task, what you're really after, and whether the ask is over- or underspecified. It drops constraints that don't serve the goal as readily as it adds ones that do, leaves your choices open, and keeps one outside check that the result is right. Then it does the work.75The agent says what a top professional would do that you didn't ask for, the rawest material they would start from, the methods and tools they would use, and what other than you or the agent would show the result is right. Then it rewrites your prompt to ask for exactly that, deeper but not bigger, and leaves your choices open. It doesn't do the task. Read the rewrite, then send it, for example with `/poteto-mode`.
76 76
77## Follow up short77## Follow up short
78 78
docs/guide/06-verify-and-ship.md+14−0
@@ -115,6 +115,20 @@ It writes a project-local skill that tells the next agent whether a change touch · 114 unmodified lines
115 115
116The 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.116The 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.
117 117
118## Show the behavior as a statechart
119
120A counterexample in model terms convinces you. The PM or designer who owns the behavior needs to see it in their terms. Run:
121
122```text
123/statechart what the user sees when they edit a note offline
124```
125
126[`/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.
127
128It 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.
129
130When 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.
131
118## Open the PR132## Open the PR
119 133
120```text134```text
docs/guide/10-recipes-and-pitfalls.md+2−2
@@ -20,13 +20,13 @@ Mechanics first, history second. Each skill's report tells you which sources it · 19 unmodified lines
20 20
21A misreading shows up in the restatement, where it costs one message to correct. Keep your own theory to yourself until the agent has stated its own. `/in-your-own-words` asks for the restatement alone, with no playbook.21A misreading shows up in the restatement, where it costs one message to correct. Keep your own theory to yourself until the agent has stated its own. `/in-your-own-words` asks for the restatement alone, with no playbook.
22 22
23## Aim a task before it starts23## Sharpen a prompt before you send it
24 24
25```text25```text
26/sharpen write the onboarding doc for the new billing service.26/sharpen write the onboarding doc for the new billing service.
27```27```
28 28
29The agent asks how an expert would do the task, what you're really after, and whether the ask says too much or too little. It keeps one outside check that the result is right, then does the work.29The agent names what an expert would do that you didn't ask for, the raw material they'd start from, their methods and tools, and an outside check that the result is right. It hands back your prompt rewritten to ask for that and doesn't do the task. Send the rewrite when it reads right.
30 30
31## Prototype before you pick31## Prototype before you pick
32 32
plugin.json+1−1
@@ -1,7 +1,7 @@
1{1{
2 "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",2 "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3 "name": "pstack",3 "name": "pstack",
4 "version": "0.15.28",4 "version": "0.15.30",
5 "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.",5 "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.",
6 "author": {6 "author": {
7 "name": "Lauren Tan"7 "name": "Lauren Tan"
skills/poteto-help/SKILL.md+2−1
@@ -67,7 +67,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste · 66 unmodified lines
67|---|---|67|---|---|
68| Do any non-trivial task with rigor | [`/poteto-mode`](../poteto-mode/SKILL.md) |68| Do any non-trivial task with rigor | [`/poteto-mode`](../poteto-mode/SKILL.md) |
69| Check that the agent understood their goal and the problem before it starts | [`/in-your-own-words`](../in-your-own-words/SKILL.md) |69| Check that the agent understood their goal and the problem before it starts | [`/in-your-own-words`](../in-your-own-words/SKILL.md) |
70| Aim a task higher before starting: more expert, more outcome-focused, not overspecified | [`/sharpen`](../sharpen/SKILL.md) |70| Get a prompt rewritten deeper, not bigger, before sending it: what a top professional would add, their raw material and methods, and an outside check. It returns the prompt and doesn't do the task | [`/sharpen`](../sharpen/SKILL.md) |
71| Know how code works now, or where new code should live | [`/how`](../how/SKILL.md) |71| Know how code works now, or where new code should live | [`/how`](../how/SKILL.md) |
72| Know why code is shaped this way, or where a number came from | [`/why`](../why/SKILL.md) |72| Know why code is shaped this way, or where a number came from | [`/why`](../why/SKILL.md) |
73| Understand a change or subsystem, explained plainly | [`/teach`](../teach/SKILL.md) |73| Understand a change or subsystem, explained plainly | [`/teach`](../teach/SKILL.md) |
@@ -88,6 +88,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste · 14 unmodified lines
88| Bring a verification skill and its feature map back in line with the app | [`/maintain-verification-skill`](../maintain-verification-skill/SKILL.md) |88| Bring a verification skill and its feature map back in line with the app | [`/maintain-verification-skill`](../maintain-verification-skill/SKILL.md) |
89| Formally verify a race, invariant, or other property that tests rarely reach | [`/create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) |89| Formally verify a race, invariant, or other property that tests rarely reach | [`/create-formal-verification-skill`](../create-formal-verification-skill/SKILL.md) |
90| 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) |90| 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) |
91| 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) |
91| Vet a performance number before reporting or acting on it | [`/benchmark-checklist`](../benchmark-checklist/SKILL.md) |92| Vet a performance number before reporting or acting on it | [`/benchmark-checklist`](../benchmark-checklist/SKILL.md) |
92| Run a large or cross-cutting change, or one to review after stepping away | [`/figure-it-out`](../figure-it-out/SKILL.md) |93| Run a large or cross-cutting change, or one to review after stepping away | [`/figure-it-out`](../figure-it-out/SKILL.md) |
93| Keep a decision log during a run, and review it afterward | [`/show-me-your-work`](../show-me-your-work/SKILL.md) |94| Keep a decision log during a run, and review it afterward | [`/show-me-your-work`](../show-me-your-work/SKILL.md) |
skills/poteto-help/references/recipes.md+1−1
@@ -6,7 +6,7 @@ Swap in the real paths, skills, and done checks. Informal wording works. · 5 unmodified lines
6 6
7- `/poteto-mode read <thread>. restate the underlying issue in your own words, in plain english.`7- `/poteto-mode read <thread>. restate the underlying issue in your own words, in plain english.`
8- `/in-your-own-words <long or messy ask>`8- `/in-your-own-words <long or messy ask>`
9- `/sharpen <your task>`9- `/sharpen <the prompt you're about to send>`
10- `/poteto-mode investigate why <symptom>. give me what we know, what data you used, and your best hypotheses. don't change any code yet.`10- `/poteto-mode investigate why <symptom>. give me what we know, what data you used, and your best hypotheses. don't change any code yet.`
11- `use /how to understand <subsystem>. then use /why to find out why it broke recently.`11- `use /how to understand <subsystem>. then use /why to find out why it broke recently.`
12- `/recall my work on <topic> from last week, then read <issue>.`12- `/recall my work on <topic> from last week, then read <issue>.`
skills/sharpen/SKILL.md+11−2
@@ -1,7 +1,16 @@
1---1---
2name: sharpen2name: sharpen
3description: "Sharpen a task before starting: the expert approach, what the user is really after, and the right level of specification. Use for /sharpen."3description: "Make a task deeper, not bigger, then rewrite the prompt. Use for /sharpen."
4disable-model-invocation: true4disable-model-invocation: true
5---5---
6 6
7Before starting, ask how a top expert would approach this task (their raw sources, their real methods, an outside check that the result is right), what the user is really after, and whether the task is over- or underspecified. Loosen constraints that don't serve the goal as readily as you add ones that do, and leave choices that are the user's to make open instead of deciding them. Fold in only what makes the work better, not bigger, but always keep one outside check that the result is right, without narrating the framing, then do the work.7I want a deeper version of this task, not a bigger one. Before rewriting it, do four things:
8
91. Say what a top professional in this field would do here that I'm not asking for.
102. Name the rawest material they would start from instead of what has already been digested for me.
113. Name the specific methods and tools they would use.
124. Say what, other than you or me, would show the result is right.
13
14Then rewrite my prompt to ask for exactly that.
15
16Leave choices that are mine to make open instead of deciding them.
skills/statechart/SKILL.md+36−0
@@ -0,0 +1,36 @@
1---
2name: statechart
3description: "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."
4disable-model-invocation: true
5---
6
7# Statechart
8
9The 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.
10
11Run `node <skill dir>/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.
12
13## Pick the direction
14
15Use `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.
16
17If 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`. Check it with the config that its authors say it passes, and pass that config as `--against-config` unless it has the spec's file name. One mapping labels both models, so label states and steps only from variables that both have. In a code model or 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. Put the file and line of the code that checks each condition of an action in a comment on that condition, as in `\* pay.ts:120`.
18
19## Spec to chart
20
211. 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.
222. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. Each rule names what the user sees on a screen or gets from a request, and the code that can change it. Refuse a rule that lacks one of them. 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.
233. 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.
244. If there is an open fix, model it from the code and pass that model as `--fixed`.
255. 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.
266. Replay each bug's story against the real code, as a test or a live repro, one step per model action. Drive only entry points that a user or client reaches, and assert only what they see. A test that asserts a flag that it sets, or that searches the source text, proves nothing. When a run proves part of the story and reading the code proves the rest, say which part each proves. When the test copies code that it cannot import, give the commit and line range of the copy, and list what the copy leaves out. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule.
27
28## Chart to spec
29
301. 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.
312. Run `from-chart`. If a rule breaks, the agreed chart has a design bug. Take the bug to the PM.
323. 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.
33
34## Hand off
35
36Give 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. With `--against`, also say whether the spec already lists it: in the configs that `stories.md` names, in the spec's notes on what the model leaves out, or not at all. Do not report that a rule holds while the command lists conditions with no file and line. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out.
skills/statechart/scripts/chart-to-tla.mjs+236−0
@@ -0,0 +1,236 @@
1#!/usr/bin/env node
2import fs from "node:fs";
3import path from "node:path";
4import process from "node:process";
5import { parseMermaid } from "./lib.mjs";
6
7const USAGE = "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]";
8const HELP = `${USAGE}
9
10Writes a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2.
11statechart.mjs from-chart runs it, then checks each INVARIANT in NAME.cfg alone.
12
13Each region becomes one variable whose values are the region's states. Each transition label
14becomes one action. Regions that share a label move together: every region that can take it does,
15and the others stay. A "%% when" line after a transition is its guard: the region can take that
16transition only while the other regions are in, or with "not" are not in, the states it names. An
17unlabeled transition is its own action. Each
18"%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\\ B). A name in a
19never line is a state, or a composite that stands for every state inside it.
20
21 --module NAME The TLA+ module name. Required.
22 --dir OUT The folder for the files. Default: the chart's folder.
23 -h, --help Print this help, and exit 0.
24
25Writes NAME.tla, NAME.cfg, and NAME.map.mjs into OUT, and replaces files with those names.
26NAME.cfg has SPECIFICATION Spec, INVARIANT TypeOK, and one INVARIANT per never line.
27NAME.map.mjs gives tlc-to-chart.mjs the reader's words: the region states joined by " | ".
28
29Exit codes
30 0 It wrote the files.
31 1 It cannot read the chart. The error says why.
32 2 A bad argument.`;
33const more = "Run node chart-to-tla.mjs --help for the flags.";
34
35const args = {};
36for (let i = 2; i < process.argv.length; i++) {
37 const a = process.argv[i];
38 if (a === "-h" || a === "--help") {
39 console.log(HELP);
40 process.exit(0);
41 } else if (a === "--module") args.module = process.argv[++i];
42 else if (a === "--dir") args.dir = process.argv[++i];
43 else if (!a.startsWith("-") && !args.chart) args.chart = a;
44 else {
45 console.error(`unknown argument ${a}. ${more}`);
46 process.exit(2);
47 }
48}
49if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) {
50 console.error(`${USAGE}\n${more}`);
51 process.exit(2);
52}
53const dir = args.dir ?? path.dirname(args.chart);
54const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8"));
55
56const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]);
57const taken = new Set(RESERVED);
58function ident(words, lower) {
59 const parts = words
60 .normalize("NFKD")
61 .replace(/[^A-Za-z0-9]+/g, " ")
62 .trim()
63 .split(" ")
64 .filter(Boolean);
65 let id =
66 parts
67 .map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1))
68 .join("") || "Step";
69 if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`;
70 let unique = id;
71 for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`;
72 taken.add(unique);
73 return unique;
74}
75const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
76const set = (xs) => `{${[...xs].map(str).join(", ")}}`;
77
78const vars = Object.entries(regions).map(([name, r]) => {
79 if (r.states.size === 0) throw new Error(`region ${name} has no states`);
80 if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`);
81 return { name, v: ident(name, true), ...r };
82});
83
84function guardOf(g) {
85 if (!g) return null;
86 const parts = Object.entries(g.states).map(([name, states]) => {
87 const { v } = vars.find((x) => x.name === name);
88 return states.length === 1 ? `${v} = ${str(states[0])}` : `${v} \\in ${set(states)}`;
89 });
90 return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ ");
91}
92
93const actions = [];
94const byLabel = new Map();
95for (const [ri, r] of vars.entries()) {
96 for (const t of r.transitions) {
97 let a = t.label ? byLabel.get(t.label) : null;
98 if (!a) {
99 a = { label: t.label, moves: new Map() };
100 actions.push(a);
101 if (t.label) byLabel.set(t.label, a);
102 }
103 if (!a.moves.has(ri)) a.moves.set(ri, []);
104 const moves = a.moves.get(ri);
105 const when = guardOf(t.guard);
106 if (!moves.some((m) => m.from === t.from && m.to === t.to && m.when === when))
107 moves.push({ from: t.from, to: t.to, when });
108 }
109}
110for (const a of actions) {
111 const only = a.label ? null : [...a.moves.values()][0][0];
112 a.name = ident(a.label || `${only.from} to ${only.to}`, false);
113}
114
115const out = [];
116const conjuncts = (lines) => lines.map((l) => ` /\\ ${l}`);
117out.push(`---- MODULE ${args.module} ----`);
118out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`);
119out.push("\\* Each region is one variable and each transition label is one action.");
120out.push("\\* Once you add guards and variables from the real code, this file is the source.");
121out.push(
122 `\\* statechart.mjs from-chart ${path.basename(args.chart)} keeps it, checks it, and compares it with the chart.`,
123);
124out.push("");
125out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`);
126out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`);
127out.push("");
128for (const x of vars)
129 out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`);
130out.push("");
131out.push("TypeOK ==", ...conjuncts(vars.map((x) => `${x.v} \\in ${x.v}States`)));
132out.push("");
133out.push(
134 "Init ==",
135 ...conjuncts(
136 vars.map((x) =>
137 x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`,
138 ),
139 ),
140);
141const enabled = (v, m) => `${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""}`;
142function whetherTakes(a, ri) {
143 const { v } = vars[ri];
144 const moves = a.moves.get(ri);
145 if (moves.every((m) => !m.when)) {
146 const from = set(new Set(moves.map((m) => m.from)));
147 return { can: `${v} \\in ${from}`, cannot: `${v} \\notin ${from}` };
148 }
149 const any = moves.map((m) => `(${enabled(v, m)})`).join(" \\/ ");
150 return { can: any, cannot: `~(${any})` };
151}
152for (const a of actions) {
153 out.push("");
154 if (a.label) out.push(`\\* ${a.label}`);
155 out.push(`${a.name} ==`);
156 const moving = [...a.moves.keys()];
157 const shared = moving.length > 1;
158 if (shared) out.push(` /\\ (${moving.map((ri) => whetherTakes(a, ri).can).join(" \\/ ")})`);
159 for (const ri of moving) {
160 const { v } = vars[ri];
161 const ds = a.moves.get(ri).map((m) => `(${enabled(v, m)} /\\ ${v}' = ${str(m.to)})`);
162 if (shared) ds.push(`(${whetherTakes(a, ri).cannot} /\\ UNCHANGED ${v})`);
163 out.push(` /\\ \\/ ${ds[0]}`);
164 for (const d of ds.slice(1)) out.push(` \\/ ${d}`);
165 }
166 const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v);
167 if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`);
168}
169out.push("");
170out.push(
171 actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars",
172);
173out.push("");
174out.push("Spec == Init /\\ [][Next]_vars");
175
176function resolve(item) {
177 const hits = [];
178 for (const [ri, x] of vars.entries()) {
179 const inside = x.states.has(item)
180 ? [item]
181 : [...x.states].filter((s) => s.startsWith(`${item}.`));
182 if (inside.length) hits.push({ ri, inside });
183 }
184 if (hits.length === 0)
185 throw new Error(`never line names "${item}", which is not a state in the chart`);
186 if (hits.length > 1)
187 throw new Error(
188 `never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`,
189 );
190 return hits[0];
191}
192const invariants = [];
193for (const n of nevers) {
194 if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`);
195 taken.add(n.name);
196 const parts = n.states.map(resolve);
197 const regionsUsed = new Set(parts.map((p) => p.ri));
198 if (regionsUsed.size !== parts.length)
199 throw new Error(
200 `never ${n.name} names two states of one region; one region is in one state at a time`,
201 );
202 const cond = parts.map((p) =>
203 p.inside.length === 1
204 ? `${vars[p.ri].v} = ${str(p.inside[0])}`
205 : `${vars[p.ri].v} \\in ${set(p.inside)}`,
206 );
207 out.push("");
208 if (n.sentence) out.push(`\\* ${n.sentence}`);
209 out.push(`${n.name} == ~(${cond.join(" /\\ ")})`);
210 invariants.push(n.name);
211}
212out.push("", "====", "");
213
214const rules = ["TypeOK", ...invariants];
215const cfg = ["SPECIFICATION Spec", ...rules.map((r) => `INVARIANT ${r}`), ""];
216
217const labels = Object.fromEntries(actions.map((a) => [a.name, a.label]));
218const map = [
219 `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`,
220 `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`,
221 `const LABELS = ${JSON.stringify(labels, null, "\t")};`,
222 "",
223 "// The actions the reader takes. facts.settled needs them. Add cause() and detail() as the spec grows.",
224 "export const readerActions = [];",
225 'export const state = (v) => regions.map((r) => v[r]).join(" | ");',
226 "export const step = (action) => LABELS[action] ?? action;",
227 "",
228];
229
230fs.mkdirSync(dir, { recursive: true });
231fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n"));
232fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n"));
233fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n"));
234console.error(
235 `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`,
236);
skills/statechart/scripts/compare-charts.mjs+104−0
@@ -0,0 +1,104 @@
1#!/usr/bin/env node
2import fs from "node:fs";
3import process from "node:process";
4import { FULL_LABELS, LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs";
5
6const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd";
7const HELP = `${USAGE}
8
9Compares two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled
10transitions. statechart.mjs from-chart runs it to check that the spec still draws the chart that
11the reader agreed to.
12
13EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer
14view with --highlight 0 --full-labels --flat, without --cover, --fixed-dot, or --against-dot. Each
15chart compares as the
16machine that its steps reach from its start states. A chart with parallel regions compares as its
17combined machine, so a guard that blocks a step in only some combinations of regions shows as a
18difference. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition is its guard:
19the step happens only while another region is in, or not in, one of those states. The label ends
20with the guard's words in [ ], which are not part of the label. A combined state is the region
21states 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
22tlc-to-chart.mjs does not draw it. An edge label "a or b" counts as two labels. An unlabeled edge
23counts only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only
24the labels.
25
26 -h, --help Print this help, and exit 0.
27
28Prints each difference and then "N difference(s)", or prints "same". Writes no file.
29
30Exit codes
31 0 The charts are the same.
32 1 The charts differ.
33 2 A bad argument, a chart that it cannot read, or an ACTUAL chart that is not drawn for
34 comparison.`;
35const more = "Run node compare-charts.mjs --help for the flags.";
36
37const files = [];
38for (const a of process.argv.slice(2)) {
39 if (a === "-h" || a === "--help") {
40 console.log(HELP);
41 process.exit(0);
42 } else if (a.startsWith("-") || files.length === 2) {
43 console.error(`unknown argument ${a}. ${more}`);
44 process.exit(2);
45 } else files.push(a);
46}
47if (files.length < 2) {
48 console.error(`${USAGE}\n${more}`);
49 process.exit(2);
50}
51const [expectedFile, actualFile] = files;
52
53const orExit = (file, f) => {
54 try {
55 return f();
56 } catch (e) {
57 console.error(`${file}: ${e.message}`);
58 process.exit(2);
59 }
60};
61const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8")));
62const has = (re) => re.test(actualText);
63const textRefusals = [
64 [
65 has(/^\s*%% second model/m),
66 "it has a second model; draw ACTUAL without --fixed-dot or --against-dot",
67 ],
68 [
69 has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m")),
70 "it has boxes; draw ACTUAL with --flat",
71 ],
72 [
73 has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+${LEGEND_ID}\\s*\\{`, "m")) &&
74 !actualText.includes(FULL_LABELS),
75 "edge labels write shared words once; draw ACTUAL with --full-labels",
76 ],
77];
78for (const [bad, why] of textRefusals) {
79 if (bad) {
80 console.error(`${actualFile}: ${why}`);
81 process.exit(2);
82 }
83}
84const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions);
85const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions);
86const numbered = Object.values(actualRegions)
87 .flatMap((r) => r.transitions)
88 .some((t) => /^\d+(, \d+)*\. /.test(t.label));
89if (numbered) {
90 console.error(
91 `${actualFile}: an edge label is a numbered path step; draw ACTUAL with --highlight 0`,
92 );
93 process.exit(2);
94}
95
96const rows = compareCharts(expectedRegions, actualRegions);
97let differences = 0;
98for (const [title, xs] of rows) {
99 console.log(`${title}:`);
100 for (const x of xs) console.log(` ${x}`);
101 differences += xs.length;
102}
103console.log(differences === 0 ? "same" : `${differences} difference(s)`);
104process.exit(differences === 0 ? 0 : 1);
skills/statechart/scripts/lib.mjs+928−0
@@ -0,0 +1,928 @@
1import fs from "node:fs";
2import process from "node:process";
3
4export function findTlc() {
5 const { TLA2TOOLS, JAVA } = process.env;
6 if (!TLA2TOOLS)
7 throw new Error(
8 "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).",
9 );
10 if (!fs.existsSync(TLA2TOOLS)) throw new Error(`TLA2TOOLS is ${TLA2TOOLS}, which does not exist.`);
11 return { jar: TLA2TOOLS, java: JAVA || "java" };
12}
13
14export function parseValue(text) {
15 const src = text.trim();
16 let i = 0;
17 const ws = () => {
18 while (i < src.length && /\s/.test(src[i])) i++;
19 };
20 const peek = (s) => src.startsWith(s, i);
21 const eat = (s) => {
22 ws();
23 if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`);
24 i += s.length;
25 };
26 const list = (close, item) => {
27 const out = [];
28 ws();
29 if (peek(close)) {
30 i += close.length;
31 return out;
32 }
33 for (;;) {
34 out.push(item());
35 ws();
36 if (peek(",")) {
37 i++;
38 continue;
39 }
40 eat(close);
41 return out;
42 }
43 };
44 const value = () => {
45 ws();
46 if (peek("<<")) {
47 i += 2;
48 return list(">>", value);
49 }
50 if (peek("{")) {
51 i++;
52 return list("}", value);
53 }
54 if (peek("[")) {
55 i++;
56 const pairs = list("]", () => {
57 ws();
58 const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
59 if (!m) throw new Error(`bad record field at ${i}`);
60 i += m[0].length;
61 eat("|->");
62 return [m[0], value()];
63 });
64 return Object.fromEntries(pairs);
65 }
66 if (peek("(")) {
67 i++;
68 const fn = {};
69 for (;;) {
70 const k = value();
71 eat(":>");
72 fn[typeof k === "string" ? k : JSON.stringify(k)] = value();
73 ws();
74 if (peek("@@")) {
75 i += 2;
76 continue;
77 }
78 eat(")");
79 return fn;
80 }
81 }
82 if (peek('"')) {
83 i++;
84 let s = "";
85 while (i < src.length && src[i] !== '"') {
86 if (src[i] === "\\") i++;
87 s += src[i++];
88 }
89 i++;
90 return s;
91 }
92 const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
93 if (!m) throw new Error(`bad value at ${i} in ${src}`);
94 i += m[0].length;
95 if (m[0] === "TRUE") return true;
96 if (m[0] === "FALSE") return false;
97 return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0];
98 };
99 try {
100 const v = value();
101 ws();
102 return i === src.length ? v : src;
103 } catch {
104 return src;
105 }
106}
107
108// One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable.
109function parseState(text) {
110 const lines = text.split("\n").filter((l) => l.trim() !== "");
111 const conjuncts = [];
112 for (const line of lines) {
113 if (/^\s*\/\\ /.test(line) || conjuncts.length === 0)
114 conjuncts.push(line.replace(/^\s*\/\\ /, ""));
115 else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`;
116 }
117 const vars = {};
118 for (const c of conjuncts) {
119 const eq = c.indexOf(" = ");
120 if (eq < 0) continue;
121 vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3));
122 }
123 return vars;
124}
125
126export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
127const canon = (v) =>
128 Array.isArray(v)
129 ? v.map(canon)
130 : isRecord(v)
131 ? Object.fromEntries(
132 Object.keys(v)
133 .sort()
134 .map((k) => [k, canon(v[k])]),
135 )
136 : v;
137export const stateKey = (vars) => JSON.stringify(canon(vars));
138
139export function ruleSentences(tla) {
140 const out = new Map();
141 const lines = tla.split("\n");
142 lines.forEach((line, i) => {
143 const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1];
144 if (!name) return;
145 const said = [];
146 for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--)
147 said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim());
148 const sentence = said.filter(Boolean).join(" ");
149 if (sentence) out.set(name, sentence);
150 });
151 return out;
152}
153
154export const tlaVariables = (code) =>
155 [...code.matchAll(/\bVARIABLES?\s+(\w+(?:\s*,\s*\w+)*)/g)].flatMap((m) => m[1].split(/\s*,\s*/));
156
157function splitCommentsKeepingOffsets(text) {
158 let code = "";
159 let notes = "";
160 let block = 0;
161 let line = false;
162 let string = false;
163 const put = (c, n) => {
164 code += c;
165 notes += n;
166 };
167 for (let i = 0; i < text.length; i++) {
168 const c = text[i];
169 const two = text.slice(i, i + 2);
170 if (c === "\n") {
171 line = string = false;
172 put(c, c);
173 } else if (block) {
174 if (two === "(*" || two === "*)") {
175 block += two === "(*" ? 1 : -1;
176 put(" ", two);
177 i++;
178 } else put(" ", c);
179 } else if (line) put(" ", c);
180 else if (string) {
181 if (c === "\\" && text[i + 1] && text[i + 1] !== "\n") {
182 put(two, " ");
183 i++;
184 } else {
185 string = c !== '"';
186 put(c, " ");
187 }
188 } else if (two === "\\*" || two === "(*") {
189 if (two === "\\*") line = true;
190 else block = 1;
191 put(" ", two);
192 i++;
193 } else {
194 string = c === '"';
195 put(c, " ");
196 }
197 }
198 return { code, notes };
199}
200
201const IDENTIFIER = /(?<![\\.\w])[A-Za-z_]\w*(?!\w)(?!\s*\|->)/g;
202const CITATION = /[\w./-]+\.[A-Za-z]\w*:\d+|#L\d+/;
203
204const DEPTH = { "(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1 };
205const LOOSER_THAN_AND = /^(\\\/|=>|<=>|~>|-\+->|\\equiv\b)/;
206const RUNS_TO_END = /^(\\[EA]|IF|LET|CASE|CHOOSE)\b/;
207export const PRIMES_OR_UNCHANGED = /[\w)\]>}]'|\bUNCHANGED\b/;
208
209function conjuncts(bare, from, to, lineStart) {
210 const first = from + bare.slice(from, to).search(/\S/);
211 if (first < from) return null;
212 if (/^\\E\b/.test(bare.slice(first, to))) {
213 for (let i = first, depth = 0; i < to; i++) {
214 depth += DEPTH[bare[i]] ?? 0;
215 if (depth === 0 && bare[i] === ":") return conjuncts(bare, i + 1, to, lineStart);
216 }
217 return null;
218 }
219 if (bare.startsWith("/\\", first)) {
220 const column = first - lineStart(first);
221 const bullets = [first];
222 for (const m of bare.slice(first, to).matchAll(/\n([ \t]*)(?=\S)/g)) {
223 if (m[1].length < column) return null;
224 const at = first + m.index + m[0].length;
225 if (m[1].length === column && bare.startsWith("/\\", at)) bullets.push(at);
226 }
227 return bullets.flatMap((b, i) => {
228 const end = bullets[i + 1] ?? to;
229 return conjuncts(bare, b + 2, end, lineStart) ?? [[b + 2, end]];
230 });
231 }
232 const cuts = [];
233 let depth = 0;
234 for (let i = first; i < to; i++) {
235 const two = bare.slice(i, i + 2);
236 const ahead = bare.slice(i, i + 7);
237 if (two === "<<" || two === ">>") {
238 depth += two === "<<" ? 1 : -1;
239 i++;
240 } else if (DEPTH[bare[i]]) depth += DEPTH[bare[i]];
241 else if (depth > 0) continue;
242 else if (LOOSER_THAN_AND.test(ahead)) return null;
243 else if (!/\w/.test(bare[i - 1] ?? "") && RUNS_TO_END.test(ahead)) break;
244 else if (two === "/\\") cuts.push(i++);
245 }
246 return [first, ...cuts.map((c) => c + 2)].map((a, i) => [a, cuts[i] ?? to]);
247}
248
249export function uncitedConditions(text) {
250 const { code, notes } = splitCommentsKeepingOffsets(text);
251 const bare = code.replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => " ".repeat(s.length));
252 const variables = new Set(tlaVariables(bare));
253 const starts = [0, ...[...bare.matchAll(/\n/g)].map((m) => m.index + 1)];
254 const lineOf = (at) => {
255 let lo = 0;
256 let hi = starts.length - 1;
257 while (lo < hi) {
258 const mid = (lo + hi + 1) >> 1;
259 if (starts[mid] <= at) lo = mid;
260 else hi = mid - 1;
261 }
262 return lo;
263 };
264 const lineStart = (at) => starts[lineOf(at)];
265 const lines = { bare: bare.split("\n"), notes: notes.split("\n") };
266
267 const defs = new Map();
268 const nextItem = /^(?![ \t\n]|\/\\|\\\/)/gm;
269 for (const m of bare.matchAll(
270 /^(?:LOCAL[ \t]+)?([A-Za-z_]\w*)[ \t]*(?:\([^)]*\)|\[[^\]]*\])?[ \t]*==/gm,
271 )) {
272 const from = m.index + m[0].length;
273 nextItem.lastIndex = from;
274 defs.set(m[1], { from, to: nextItem.exec(bare)?.index ?? bare.length, line: lineOf(m.index) });
275 }
276 const direct = (body) => {
277 const ids = body.match(IDENTIFIER) ?? [];
278 return {
279 reads: ids.filter((id) => variables.has(id)),
280 writes: [...body.matchAll(/(?<![\\.\w])([A-Za-z_]\w*)'/g)]
281 .map((m) => m[1])
282 .filter((id) => variables.has(id)),
283 acts: PRIMES_OR_UNCHANGED.test(body),
284 calls: [...new Set(ids.filter((id) => defs.has(id)))],
285 };
286 };
287 const own = new Map([...defs].map(([name, d]) => [name, direct(bare.slice(d.from, d.to))]));
288 const through = (start) => {
289 const out = { reads: new Set(start.reads), writes: new Set(start.writes), acts: start.acts };
290 const seen = new Set(start.calls);
291 const todo = [...seen];
292 while (todo.length) {
293 const d = own.get(todo.pop());
294 d.reads.forEach((v) => out.reads.add(v));
295 d.writes.forEach((v) => out.writes.add(v));
296 out.acts ||= d.acts;
297 for (const c of d.calls.filter((c) => !seen.has(c))) {
298 seen.add(c);
299 todo.push(c);
300 }
301 }
302 return out;
303 };
304 const cited = (from, to, { above }) => {
305 for (let l = from; l <= to; l++) if (CITATION.test(lines.notes[l])) return true;
306 for (let l = from - 1; above && !lines.bare[l].trim() && lines.notes[l].trim(); l--)
307 if (CITATION.test(lines.notes[l])) return true;
308 return false;
309 };
310
311 const found = [];
312 for (const [action, d] of defs) {
313 if (!own.get(action).acts) continue;
314 const changes = through(own.get(action)).writes;
315 for (const [a, b] of conjuncts(bare, d.from, d.to, lineStart) ?? []) {
316 const part = through(direct(bare.slice(a, b)));
317 if (part.acts || [...part.reads].every((v) => changes.has(v))) continue;
318 const first = lineOf(a + bare.slice(a, b).search(/\S/));
319 const last = lineOf(a + bare.slice(a, b).trimEnd().length - 1);
320 if (cited(first, last, { above: first > d.line })) continue;
321 found.push({
322 line: first + 1,
323 action,
324 condition: code.slice(a, b).replace(/\s+/g, " ").trim(),
325 });
326 }
327 }
328 return found;
329}
330
331function unescapeDot(s) {
332 let out = "";
333 for (let i = 0; i < s.length; i++) {
334 if (s[i] === "\\" && i + 1 < s.length) {
335 const n = s[++i];
336 out += n === "n" ? "\n" : n;
337 } else out += s[i];
338 }
339 return out;
340}
341
342export function parseDot(text) {
343 const nodes = new Map();
344 const edges = [];
345 const loops = [];
346 const seen = new Set();
347 const LABEL = '"((?:[^"\\\\]|\\\\.)*)"';
348 const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`);
349 const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`);
350 for (const line of text.split("\n")) {
351 let m = edgeRe.exec(line);
352 if (m) {
353 const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" };
354 const id = `${e.from} ${e.to} ${e.action}`;
355 if (seen.has(id)) continue;
356 seen.add(id);
357 (e.from === e.to ? loops : edges).push(e);
358 continue;
359 }
360 m = nodeRe.exec(line);
361 if (m)
362 nodes.set(m[1], {
363 id: m[1],
364 vars: parseState(unescapeDot(m[2])),
365 init: /style\s*=\s*filled/.test(m[3]),
366 });
367 }
368 if (nodes.size === 0)
369 throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels <file>");
370 const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to));
371 if (missing)
372 throw new Error(
373 `transition ${missing.from} -> ${missing.to} names a state the file does not define`,
374 );
375 if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action))
376 throw new Error(
377 "the graph has no action names; dump it with TLC -dump dot,actionlabels <file>",
378 );
379 return { nodes, edges, loops };
380}
381
382export function parseTraces(text) {
383 const traces = [];
384 let invariant = null;
385 let current = null;
386 const lines = text.split("\n");
387 const stateAt = (i) => {
388 const body = [];
389 while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]);
390 return [parseState(body.join("\n")), i];
391 };
392 for (let i = 0; i < lines.length; i++) {
393 const line = lines[i];
394 const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line);
395 if (inv) {
396 invariant = inv[1];
397 if (inv[2]) {
398 const [vars, end] = stateAt(i);
399 traces.push({ invariant, steps: [{ action: null, vars }] });
400 i = end;
401 }
402 continue;
403 }
404 if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is"))
405 invariant = null;
406 const head = /^State (\d+): <([^\s>(]+)/.exec(line);
407 if (!head) continue;
408 const [vars, end] = stateAt(i);
409 i = end;
410 if (head[1] === "1") {
411 current = { invariant, steps: [] };
412 traces.push(current);
413 }
414 current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars });
415 }
416 return traces;
417}
418
419// TLC words a broken PROPERTY by its form: [](state predicate) as an invariant, [][A]_v as an
420// action property, a bare state predicate, which TLC checks only in the initial states, as a
421// property of the initial state, and any other formula as a temporal property.
422export const BROKEN_PROPERTY =
423 /^Error: (Invariant \S+ is violated|Action property \S+ is violated\.|Property \S+ is violated by the initial state:|Temporal properties were violated\.)/m;
424
425// With -continue, TLC prints "Model checking completed" even after broken invariants, and not
426// when it stopped early. TLC can also throw a Java exception of its own while it reports a state
427// that breaks a rule, and stop early. An error in the rule's formula throws one of TLC's own
428// exceptions in place of java.*.
429export function tlcOutputWarning(text, file) {
430 const lines = text.split("\n");
431 const named = (re) => new Set(lines.map((l) => re.exec(l)?.[1]).filter(Boolean));
432 const broken = named(/^Error: Invariant (\S+) is violated/);
433 const failed = [...named(/^Error: Evaluating invariant (\S+) failed\./)];
434 const thrown = [...text.matchAll(/^The exception was a (\S+)\n: (?:([\w.$]+):)?(.*)$/gm)].map(
435 ([, cls, cause, message]) => `${cause ?? cls}: ${message.trim()}`,
436 );
437 const crashed =
438 failed.length > 0 &&
439 failed.every((r) => broken.has(r)) &&
440 thrown.length > 0 &&
441 thrown.every((t) => t.startsWith("java."));
442 const reported = crashed
443 ? /^Error: (Invariant \S+ is violated|The behavior up to this point is|Evaluating invariant \S+ failed\.|TLC threw an unexpected exception\.|The error occurred when TLC was evaluating the nested)/
444 : /^Error: (Invariant \S+ is violated|Action property \S+ is violated\.|Property \S+ is violated by the initial state:|The behavior up to this point is|Temporal properties were violated\.|The following behavior constitutes a counter-example:)/;
445 const other = lines.find((l) => l.startsWith("Error: ") && !reported.test(l));
446 if (other)
447 throw new Error(
448 `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`,
449 );
450 if (crashed) {
451 const rules = failed.join(", ");
452 return `TLC found that ${rules} ${failed.length === 1 ? "is" : "are"} broken, and then failed with ${thrown[0]}. TLC stopped before it checked every state, so the charts can miss states that break ${rules}. The failure is in TLC, not in the rule. Do not weaken ${rules} because of it. A smaller constant can let TLC finish.`;
453 }
454 // TLC stops at the first broken temporal property, and at an initial state that breaks a
455 // PROPERTY, and then does not print that it completed.
456 if (
457 !/^(Model checking completed\.|Error: (Temporal properties were violated\.|Property \S+ is violated by the initial state:))/m.test(
458 text,
459 )
460 )
461 throw new Error(
462 `${file}: TLC did not finish, so its counterexamples are not all there; read the file`,
463 );
464 return null;
465}
466
467export function wrap(text, width = 26) {
468 const words = String(text).split(/\s+/);
469 const rows = [];
470 let row = "";
471 for (const w of words) {
472 if (row && (row + " " + w).length > width) {
473 rows.push(row);
474 row = w;
475 } else row = row ? `${row} ${w}` : w;
476 }
477 if (row) rows.push(row);
478 return rows.join("<br/>");
479}
480
481export const joinAnd = (items) =>
482 items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
483
484const OPENS_A_PHRASE = new Set(
485 "a an the your my our their its this that to of in on at for with from by and or".split(" "),
486);
487export function joinLabels(labels) {
488 if (labels.length < 2) return labels.join(" or ");
489 const words = labels.map((l) => String(l).split(" "));
490 let n = 0;
491 while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++;
492 while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--;
493 if (n < 2) return labels.join(" or ");
494 const rest = words.map((w) => w.slice(n).join(" "));
495 const list =
496 rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`;
497 return `${words[0].slice(0, n).join(" ")} ${list}`;
498}
499
500const unwrap = (s) =>
501 s
502 .replace(/<br\s*\/?>|#160;/g, " ")
503 .replace(/#(quot|34);/g, '"')
504 .replace(/\s+/g, " ")
505 .trim();
506
507export const escapeMermaidQuotes = (s) => String(s).replaceAll('"', "#quot;");
508
509export const LEGEND_ID = "legend";
510export const FULL_LABELS = "%% labels in full";
511
512export function parseMermaid(text) {
513 const names = new Map();
514 const nevers = [];
515 let last = null;
516 const newPart = () => ({ children: [], transitions: [] });
517 const root = { name: "", parts: [newPart()] };
518 const stack = [root];
519 const part = () => stack[stack.length - 1].parts.at(-1);
520 const display = (id) => names.get(id) ?? id;
521 const implicit = new Map();
522 const declare = (id) => {
523 const prev = implicit.get(id);
524 if (prev)
525 prev.children.splice(
526 prev.children.findIndex((c) => c.name === id),
527 1,
528 );
529 implicit.set(id, null);
530 };
531 const mention = (id) => {
532 if (id === "[*]" || implicit.has(id)) return;
533 part().children.push({ name: id, leaf: true });
534 implicit.set(id, part());
535 };
536 const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, "");
537 for (const raw of body.split("\n")) {
538 const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1");
539 const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line);
540 if (when) {
541 if (!last) throw new Error(`a %% when line must come right after a transition: ${line}`);
542 if (!last.words)
543 throw new Error(
544 `the guarded step "${last.label}" must end its label with the guard's words in [ ]`,
545 );
546 last.when = { not: Boolean(when[1]), ids: when[2].split(",").map((s) => s.trim()) };
547 last = null;
548 continue;
549 }
550 last = null;
551 const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line);
552 if (never) {
553 nevers.push({
554 name: never[1],
555 states: never[2].split("&").map((s) => s.trim()),
556 sentence: never[3] ?? "",
557 });
558 continue;
559 }
560 if (
561 !line ||
562 line.startsWith("%%") ||
563 /^(stateDiagram|direction|classDef|class |note)/.test(line)
564 )
565 continue;
566 let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line);
567 if (!m) {
568 const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line);
569 if (plain) m = [line, null, plain[1], plain[2]];
570 }
571 if (m) {
572 if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1]));
573 declare(m[2]);
574 if (m[3]) {
575 const node = { name: m[2], parts: [newPart()] };
576 part().children.push(node);
577 stack.push(node);
578 } else part().children.push({ name: m[2], leaf: true });
579 continue;
580 }
581 if (line === "}") {
582 if (stack.length === 1) throw new Error("unmatched } in the chart");
583 stack.pop();
584 continue;
585 }
586 if (line === "--") {
587 stack[stack.length - 1].parts.push(newPart());
588 continue;
589 }
590 m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line);
591 if (m) {
592 if (m[2] === "[*]" && m[1] !== "[*]")
593 throw new Error(
594 `cannot read final state in "${line}"; draw the step to the state the reader sees next`,
595 );
596 mention(m[1]);
597 mention(m[2]);
598 const said = m[3] ? unwrap(m[3]) : "";
599 const label = said.replace(/\s*\[[^\]]*\]/g, "");
600 last = { from: m[1], to: m[2], label, words: label !== said };
601 part().transitions.push(last);
602 continue;
603 }
604 m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line);
605 if (m) {
606 if (!names.has(m[1])) names.set(m[1], unwrap(m[2]));
607 if (implicit.get(m[1]) === null) continue;
608 declare(m[1]);
609 part().children.push({ name: m[1], leaf: true });
610 continue;
611 }
612 throw new Error(`cannot read chart line: ${line}`);
613 }
614 if (stack.length !== 1) throw new Error("the chart has an unclosed {");
615 if (root.parts.length > 1)
616 throw new Error(
617 "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --",
618 );
619
620 function region(p) {
621 const out = { states: new Set(), initial: new Set(), transitions: [] };
622 const info = new Map();
623 const declaredInRegion = new Set();
624 (function collect(q) {
625 for (const c of q.children) {
626 if (c.name === LEGEND_ID) continue;
627 declaredInRegion.add(c.name);
628 if (!c.leaf) for (const sub of c.parts) collect(sub);
629 }
630 })(p);
631 const transitions = [];
632 const lookup = (id) => {
633 const found = info.get(id);
634 if (!found) throw new Error(`transition names ${id}, which is not in this region`);
635 return found;
636 };
637 function states(q, prefix, depth) {
638 const local = q.children.filter((c) => c.name !== LEGEND_ID);
639 for (const t of q.transitions) {
640 for (const end of [t.from, t.to]) {
641 if (end === "[*]" || declaredInRegion.has(end)) continue;
642 declaredInRegion.add(end);
643 local.push({ name: end, leaf: true });
644 }
645 }
646 const all = [];
647 for (const c of local) {
648 if (info.has(c.name)) {
649 if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue;
650 throw new Error(`state id ${c.name} is declared in two places`);
651 }
652 const full = `${prefix}${display(c.name)}`;
653 if (c.leaf) {
654 if (out.states.has(full))
655 throw new Error(`two states have the caption "${full}"; give each its own caption`);
656 out.states.add(full);
657 info.set(c.name, { all: [full], initial: [full], depth });
658 all.push(full);
659 continue;
660 }
661 if (c.parts.length > 1)
662 throw new Error(
663 `${display(c.name)} splits into regions below the top level; make each region a top-level region`,
664 );
665 const inner = states(c.parts[0], `${full}.`, depth + 1);
666 const initial = c.parts[0].transitions
667 .filter((t) => t.from === "[*]")
668 .flatMap((t) => lookup(t.to).initial);
669 if (initial.length === 0)
670 throw new Error(
671 `${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`,
672 );
673 info.set(c.name, { all: inner, initial, depth });
674 all.push(...inner);
675 }
676 for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t);
677 return all;
678 }
679 states(p, "", 0);
680 for (const t of p.transitions)
681 if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s);
682 transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth);
683 const handled = new Set();
684 let pending = [];
685 let depth = null;
686 for (const t of transitions) {
687 const from = lookup(t.from);
688 if (from.depth !== depth) {
689 for (const h of pending) handled.add(h);
690 pending = [];
691 depth = from.depth;
692 }
693 for (const f of from.all) {
694 const key = `${f}\u0000${t.label}`;
695 if (t.label && handled.has(key)) continue;
696 for (const to of lookup(t.to).initial)
697 out.transitions.push({
698 from: f,
699 to,
700 label: t.label,
701 ...(t.when ? { when: t.when } : {}),
702 });
703 if (t.label) pending.push(key);
704 }
705 }
706 return [out, info];
707 }
708
709 const owners = new Map();
710 const regions = {};
711 const top = root.parts[0];
712 const machine = top.children.filter((c) => c.name !== LEGEND_ID);
713 const parallel =
714 machine.length === 1 &&
715 !machine[0].leaf &&
716 machine[0].parts.length > 1 &&
717 top.transitions.every((t) => t.from === "[*]");
718 if (parallel) {
719 for (const p of machine[0].parts) {
720 const [only] = p.children;
721 const holder =
722 p.children.length === 1 &&
723 !only.leaf &&
724 p.transitions.every((t) => t.from === "[*]" && t.to === only.name)
725 ? only
726 : null;
727 if (holder && holder.parts.length > 1)
728 throw new Error(`${display(holder.name)} splits into regions below the top level`);
729 const name = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`;
730 const [r, ids] = region(holder ? holder.parts[0] : p);
731 regions[name] = r;
732 for (const [id, { all }] of ids) owners.set(id, { name, all });
733 }
734 } else {
735 const [r, ids] = region(top);
736 regions.Main = r;
737 for (const [id, { all }] of ids) owners.set(id, { name: "Main", all });
738 }
739 for (const [name, r] of Object.entries(regions))
740 for (const t of r.transitions) {
741 if (!t.when) continue;
742 if (!parallel)
743 throw new Error("a %% when line guards a step on other regions, and the chart has none");
744 const states = {};
745 for (const id of t.when.ids) {
746 const at = owners.get(id);
747 if (!at)
748 throw new Error(`a %% when line names ${id}, which is not a state id in the chart`);
749 if (at.name === name)
750 throw new Error(
751 `a %% when line names ${display(id)}, which is in the step's own region; a guard reads other regions`,
752 );
753 states[at.name] = [...new Set([...(states[at.name] ?? []), ...at.all])].sort();
754 }
755 t.guard = { not: t.when.not, states };
756 delete t.when;
757 }
758 return { regions, nevers };
759}
760
761function guardHolds(guard, at) {
762 if (!guard) return true;
763 const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r]));
764 return guard.not ? !hits.some(Boolean) : hits.every(Boolean);
765}
766
767export function productOf(regions) {
768 const names = Object.keys(regions);
769 const rs = Object.values(regions);
770 const join = (parts) => parts.join(" | ");
771 const byLabel = new Map();
772 const unlabeled = [];
773 rs.forEach((r, ri) => {
774 for (const t of r.transitions) {
775 if (!t.label) {
776 unlabeled.push({ ri, from: t.from, to: t.to, guard: t.guard });
777 continue;
778 }
779 if (!byLabel.has(t.label))
780 byLabel.set(
781 t.label,
782 rs.map(() => new Map()),
783 );
784 const moves = byLabel.get(t.label)[ri];
785 if (!moves.has(t.from)) moves.set(t.from, []);
786 moves.get(t.from).push({ to: t.to, guard: t.guard });
787 }
788 });
789 let starts = [[]];
790 for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s]));
791 const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] };
792 const seen = new Set(starts.map(join));
793 const queue = [...starts];
794 const step = (from, to, label) => {
795 const [a, b] = [join(from), join(to)];
796 if (a === b) return;
797 out.transitions.push({ from: a, to: b, label });
798 if (!seen.has(b)) {
799 seen.add(b);
800 queue.push(to);
801 }
802 };
803 while (queue.length) {
804 const cur = queue.shift();
805 out.states.add(join(cur));
806 const at = Object.fromEntries(names.map((n, ri) => [n, cur[ri]]));
807 for (const u of unlabeled) {
808 if (cur[u.ri] !== u.from || !guardHolds(u.guard, at)) continue;
809 const next = [...cur];
810 next[u.ri] = u.to;
811 step(cur, next, "");
812 }
813 for (const [label, perRegion] of byLabel) {
814 const open = perRegion.map((moves, ri) =>
815 (moves.get(cur[ri]) ?? []).filter((m) => guardHolds(m.guard, at)).map((m) => m.to),
816 );
817 if (open.every((tos) => tos.length === 0)) continue;
818 let nexts = [[]];
819 open.forEach((tos, ri) => {
820 const options = tos.length ? [...new Set(tos)] : [cur[ri]];
821 nexts = nexts.flatMap((p) => options.map((s) => [...p, s]));
822 });
823 for (const next of nexts) step(cur, next, label);
824 }
825 }
826 return out;
827}
828
829export function compareCharts(expectedRegions, actualRegions) {
830 const expected = productOf(expectedRegions);
831 const actual = productOf(actualRegions);
832 const edgeSet = (m) => {
833 const labeled = new Set();
834 const out = new Set();
835 for (const t of m.transitions) {
836 if (!t.label) continue;
837 labeled.add(`${t.from} --> ${t.to}`);
838 for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`);
839 }
840 for (const t of m.transitions)
841 if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`);
842 return out;
843 };
844 const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort();
845 const unreached = [];
846 const regionEntries = Object.entries(expectedRegions);
847 if (regionEntries.length === 1) {
848 for (const s of regionEntries[0][1].states) if (!expected.states.has(s)) unreached.push(s);
849 } else {
850 regionEntries.forEach(([name, r], ri) => {
851 const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri]));
852 for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`);
853 });
854 }
855 return [
856 ["states the agreed chart's own steps never reach", unreached.sort()],
857 [
858 "states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)",
859 minus(expected.states, actual.states),
860 ],
861 ["states only in ACTUAL", minus(actual.states, expected.states)],
862 ["start states only in EXPECTED", minus(expected.initial, actual.initial)],
863 ["start states only in ACTUAL", minus(actual.initial, expected.initial)],
864 [
865 "transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)",
866 minus(edgeSet(expected), edgeSet(actual)),
867 ],
868 ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))],
869 ].filter(([, xs]) => xs.length > 0);
870}
871
872export const STATUS_CLASSES = [
873 "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631",
874 "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c",
875 "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32",
876 "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631",
877 "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c",
878 "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32",
879];
880
881export const GROUP_PALETTE = [
882 "#d6eaf8",
883 "#fdebd0",
884 "#e8daef",
885 "#fcf3cf",
886 "#d1f2eb",
887 "#ede0d4",
888 "#f6e0ef",
889];
890export const PLAIN_FILL = "#f4f6f7";
891
892const rgb = (hex) => {
893 const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1);
894 return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
895};
896function wcagRelativeLuminance(hex) {
897 const [r, g, b] = rgb(hex).map((c) => {
898 const s = c / 255;
899 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
900 });
901 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
902}
903export function groupColorProblem(color) {
904 if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color))
905 return 'is not "#rgb" or "#rrggbb"';
906 if (wcagRelativeLuminance(color) < 0.5) return "is too dark for the status borders to show on it";
907 return null;
908}
909
910const BORDERS = {
911 Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"],
912 Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"],
913 Path: ["stroke:#1f3a93,stroke-width:4px", "#1b2631"],
914};
915export function groupClassDef(base, fill, border) {
916 const darker = `#${rgb(fill)
917 .map((c) =>
918 Math.round(c / 2)
919 .toString(16)
920 .padStart(2, "0"),
921 )
922 .join("")}`;
923 const [stroke, color] = BORDERS[border] ?? [
924 `stroke:${fill === PLAIN_FILL ? "#566573" : darker}`,
925 "#1b2631",
926 ];
927 return `classDef ${base}${border} fill:${fill},${stroke},color:${color}`;
928}
skills/statechart/scripts/model/OutputFolder.cfg+6−0
@@ -0,0 +1,6 @@
1SPECIFICATION Spec
2INVARIANT ChartsOneSpec
3INVARIANT KeepsYourFiles
4INVARIANT NeverLosesChart
5INVARIANT ExitTwoLeavesFolder
6INVARIANT OverlapNeverRefuses
skills/statechart/scripts/model/OutputFolder.map.mjs+106−0
@@ -0,0 +1,106 @@
1const QUIET = ["idle", "exited", "stopped", "orphanTlc"];
2const going = (v) => v.pc.filter((p) => !QUIET.includes(p)).length;
3const changed = (before, after) =>
4 after.pc.findIndex(
5 (p, i) => p !== before.pc[i] || after.aside[i].exists !== before.aside[i].exists,
6 ) + 1;
7
8export const readerActions = ["Start", "Edit", "Note", "Stop", "KillNode"];
9
10function context(v) {
11 if (v.pc.every((p) => p === "idle")) return "Before any run";
12 if (going(v) === 2) return "Two runs going";
13 return going(v) === 1 ? "One run going" : "Done";
14}
15
16const SPECS = { 1: "the spec", 2: "the edited spec", "1,2": "both specs" };
17
18function contents(v) {
19 const kept = v.aside.filter((a) => a.exists);
20 const aside = kept.some((a) => a.notes)
21 ? ", your notes moved aside"
22 : kept.some((a) => a.by)
23 ? ", the old chart moved aside"
24 : "";
25 const parts = [
26 v.out.by && `chart of ${SPECS[v.out.read.join()]}`,
27 v.out.notes && "your notes",
28 ].filter(Boolean);
29 if (!v.out.exists) return `no folder${aside}`;
30 return `${parts.join(" and ") || "empty folder"}${aside}`;
31}
32
33export function state(v) {
34 return `${context(v)}: ${contents(v)}`;
35}
36
37export function group(v) {
38 if (v.pc.every((p) => p === "idle")) return "Not started";
39 return going(v) ? "Running" : "Finished";
40}
41
42export function step(action, before, after) {
43 const r = changed(before, after);
44 switch (action) {
45 case "Start": {
46 const where = going(before) ? " in a second shell" : "";
47 const how = after.inputOk[r - 1] ? "" : " with a typo";
48 return `you start run ${r}${where}${how}`;
49 }
50 case "Refuse":
51 return before.inputOk[r - 1]
52 ? `run ${r} says the folder has a file that it did not write`
53 : `run ${r} says the input is wrong`;
54 case "Tlc":
55 return `run ${r}'s TLC reads the spec`;
56 case "Draw":
57 return `run ${r} draws the chart in its own folder`;
58 case "Stale":
59 return `run ${r} says the spec changed while it read it`;
60 case "Foreign":
61 return `run ${r} says the folder has a file that it did not write`;
62 case "Swap":
63 case "Checked":
64 return "";
65 case "MoveIn":
66 return `run ${r} moves its folder into place`;
67 case "MoveAside":
68 return `run ${r} moves the old folder aside`;
69 case "Restore":
70 return `run ${r} puts the folder with your notes back and stops`;
71 case "Supersede":
72 return `run ${r} finds another run's folder in place and deletes the old folder`;
73 case "KeepAside":
74 return `run ${r} keeps the folder with your notes aside and says where`;
75 case "RenameFails":
76 if (after.aside[r - 1].exists)
77 return `run ${r} cannot rename a folder and says where the old folder is`;
78 return after.out.exists && !before.out.exists
79 ? `run ${r} cannot rename a folder, puts the old folder back, and stops`
80 : `run ${r} cannot rename a folder and stops`;
81 case "Clean":
82 return `run ${r} deletes the old folder and finishes`;
83 case "Edit":
84 return "you edit the spec";
85 case "Note":
86 return "you save notes in the folder";
87 case "Stop":
88 return `you stop run ${r} with Ctrl-C`;
89 case "KillNode":
90 return `you kill run ${r}'s Node, and its TLC keeps running`;
91 case "OrphanEnd":
92 return `run ${r}'s orphaned TLC finishes`;
93 default:
94 return action;
95 }
96}
97
98export function cause(v, facts, invariant) {
99 return {
100 ChartsOneSpec: "the run read the spec before and after an edit, and still wrote its chart",
101 KeepsYourFiles: "a run deleted a folder that had your notes in it",
102 NeverLosesChart: "a run deleted the last chart before another chart was in place",
103 ExitTwoLeavesFolder: "a run wrote the folder and then said it could not finish",
104 OverlapNeverRefuses: "a run stopped only because another run overlapped it",
105 }[invariant];
106}
skills/statechart/scripts/model/OutputFolder.tla+176−0
@@ -0,0 +1,176 @@
1---- MODULE OutputFolder ----
2EXTENDS Naturals
3
4Runs == {1, 2}
5NoRun == 0
6NoFolder == [exists |-> FALSE, by |-> NoRun, read |-> {}, notes |-> FALSE]
7
8VARIABLES pc, inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode
9
10vars == <<pc, inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
11
12Init ==
13 /\ pc = [r \in Runs |-> "idle"]
14 /\ inputOk = [r \in Runs |-> TRUE]
15 /\ snap = [r \in Runs |-> 0]
16 /\ read = [r \in Runs |-> {}]
17 /\ out = NoFolder
18 /\ aside = [r \in Runs |-> NoFolder]
19 /\ spec = 1
20 /\ noted = FALSE
21 /\ wrote = [r \in Runs |-> FALSE]
22 /\ broke = [r \in Runs |-> FALSE]
23 /\ exitCode = [r \in Runs |-> "none"]
24
25Set(f, r, v) == [f EXCEPT ![r] = v]
26Going(r) == pc[r] \notin {"idle", "exited", "stopped", "orphanTlc"}
27Go(r, to) == pc' = Set(pc, r, to)
28Exit(r, code) == pc' = Set(pc, r, "exited") /\ exitCode' = Set(exitCode, r, code)
29
30Start(r) ==
31 /\ pc[r] = "idle"
32 /\ r = 2 => pc[1] # "idle"
33 /\ \E ok \in BOOLEAN : inputOk' = Set(inputOk, r, ok)
34 /\ snap' = Set(snap, r, spec)
35 /\ Go(r, "check")
36 /\ UNCHANGED <<read, out, aside, spec, noted, wrote, broke, exitCode>>
37
38Refuse(r) ==
39 /\ pc[r] = "check"
40 /\ ~inputOk[r] \/ out.notes
41 /\ Exit(r, "2")
42 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke>>
43
44Tlc(r) ==
45 /\ \/ pc[r] = "check" /\ inputOk[r] /\ ~out.notes
46 \/ pc[r] = "tlc"
47 /\ read' = Set(read, r, read[r] \cup {spec})
48 /\ Go(r, IF pc[r] = "check" THEN "tlc" ELSE "draw")
49 /\ UNCHANGED <<inputOk, snap, out, aside, spec, noted, wrote, broke, exitCode>>
50
51Draw(r) ==
52 /\ pc[r] = "draw"
53 /\ Go(r, "publish")
54 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
55
56Stale(r) ==
57 /\ pc[r] = "publish" /\ spec # snap[r]
58 /\ Exit(r, "2")
59 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke>>
60
61Foreign(r) ==
62 /\ pc[r] = "publish" /\ spec = snap[r] /\ out.notes
63 /\ Exit(r, "2")
64 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke>>
65
66Swap(r) ==
67 /\ pc[r] = "publish" /\ spec = snap[r] /\ ~out.notes
68 /\ Go(r, "swap")
69 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
70
71MoveIn(r) ==
72 /\ pc[r] = "swap" /\ ~out.exists
73 /\ out' = [exists |-> TRUE, by |-> r, read |-> read[r], notes |-> FALSE]
74 /\ wrote' = Set(wrote, r, TRUE)
75 /\ Go(r, "clean")
76 /\ UNCHANGED <<inputOk, snap, read, aside, spec, noted, broke, exitCode>>
77
78MoveAside(r) ==
79 /\ pc[r] = "swap" /\ out.exists /\ ~aside[r].exists
80 /\ aside' = Set(aside, r, out)
81 /\ out' = NoFolder
82 /\ Go(r, "aside")
83 /\ UNCHANGED <<inputOk, snap, read, spec, noted, wrote, broke, exitCode>>
84
85Checked(r) ==
86 /\ pc[r] = "aside" /\ ~aside[r].notes
87 /\ Go(r, "swap")
88 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
89
90Supersede(r) ==
91 /\ pc[r] = "swap" /\ out.exists /\ aside[r].exists /\ ~out.notes
92 /\ aside' = Set(aside, r, NoFolder)
93 /\ UNCHANGED <<pc, inputOk, snap, read, out, spec, noted, wrote, broke, exitCode>>
94
95Restore(r) ==
96 /\ pc[r] = "aside" /\ aside[r].notes /\ ~out.exists
97 /\ out' = aside[r]
98 /\ aside' = Set(aside, r, NoFolder)
99 /\ Exit(r, "2")
100 /\ UNCHANGED <<inputOk, snap, read, spec, noted, wrote, broke>>
101
102KeepAside(r) ==
103 /\ \/ pc[r] = "aside" /\ aside[r].notes /\ out.exists
104 \/ pc[r] = "swap" /\ aside[r].exists /\ out.notes
105 /\ Exit(r, "2")
106 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke>>
107
108RenameFails(r) ==
109 /\ pc[r] \in {"swap", "aside"}
110 /\ \/ /\ aside[r].exists /\ ~out.exists
111 /\ out' = aside[r]
112 /\ aside' = Set(aside, r, NoFolder)
113 \/ UNCHANGED <<out, aside>>
114 /\ broke' = Set(broke, r, TRUE)
115 /\ Exit(r, "2")
116 /\ UNCHANGED <<inputOk, snap, read, spec, noted, wrote>>
117
118Clean(r) ==
119 /\ pc[r] = "clean"
120 /\ aside' = Set(aside, r, NoFolder)
121 /\ Exit(r, "0")
122 /\ UNCHANGED <<inputOk, snap, read, out, spec, noted, wrote, broke>>
123
124Stop(r) ==
125 /\ Going(r)
126 /\ Go(r, "stopped")
127 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
128
129KillNode(r) ==
130 /\ pc[r] = "tlc"
131 /\ Go(r, "orphanTlc")
132 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
133
134OrphanEnd(r) ==
135 /\ pc[r] = "orphanTlc"
136 /\ Go(r, "stopped")
137 /\ UNCHANGED <<inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode>>
138
139Edit ==
140 /\ spec = 1
141 /\ spec' = 2
142 /\ UNCHANGED <<pc, inputOk, snap, read, out, aside, noted, wrote, broke, exitCode>>
143
144Note ==
145 /\ ~noted
146 /\ noted' = TRUE
147 /\ out' = [out EXCEPT !.exists = TRUE, !.notes = TRUE]
148 /\ UNCHANGED <<pc, inputOk, snap, read, aside, spec, wrote, broke, exitCode>>
149
150Next ==
151 \/ Edit \/ Note
152 \/ \E r \in Runs :
153 Start(r) \/ Refuse(r) \/ Tlc(r) \/ Draw(r) \/ Stale(r) \/ Foreign(r) \/ Swap(r)
154 \/ MoveIn(r) \/ MoveAside(r) \/ Checked(r) \/ Supersede(r) \/ Restore(r)
155 \/ KeepAside(r) \/ RenameFails(r) \/ Clean(r) \/ Stop(r) \/ KillNode(r) \/ OrphanEnd(r)
156
157Spec == Init /\ [][Next]_vars
158
159\* The folder never holds a chart drawn from two versions of the spec.
160ChartsOneSpec == out.read \in {{}, {1}, {2}}
161
162\* A file that you put in the folder is never deleted.
163KeepsYourFiles == noted => out.notes \/ \E r \in Runs : aside[r].notes
164
165\* Once a run writes a chart, the folder or a folder that a run moved aside always holds one.
166NeverLosesChart ==
167 (\E r \in Runs : wrote[r]) => out.by # NoRun \/ \E r \in Runs : aside[r].by # NoRun
168
169\* A run that exits 2 did not write the folder.
170ExitTwoLeavesFolder == \A r \in Runs : exitCode[r] = "2" => ~wrote[r]
171
172\* A run exits 2 only for a wrong input, an edited spec, a file that it did not write, or a failed
173\* rename, never because another run overlaps it.
174OverlapNeverRefuses ==
175 \A r \in Runs : exitCode[r] = "2" => ~inputOk[r] \/ spec # snap[r] \/ noted \/ broke[r]
176====
skills/statechart/scripts/statechart.mjs+961−0
@@ -0,0 +1,961 @@
1#!/usr/bin/env node
2import { spawnSync } from "node:child_process";
3import fs from "node:fs";
4import os from "node:os";
5import path from "node:path";
6import process from "node:process";
7import { fileURLToPath, pathToFileURL } from "node:url";
8import {
9 BROKEN_PROPERTY,
10 findTlc,
11 joinAnd,
12 parseDot,
13 PRIMES_OR_UNCHANGED,
14 tlaVariables,
15 tlcOutputWarning,
16 uncitedConditions,
17} from "./lib.mjs";
18
19const here = path.dirname(fileURLToPath(import.meta.url));
20
21const USAGE = {
22 "from-spec":
23 "node statechart.mjs from-spec SPEC.tla [--config FILE] [--fixed FIXED.tla [--fixed-config FILE] | --against AGREED.tla [--against-config FILE]] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]",
24 "from-chart":
25 "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]",
26};
27const usage = (lines) => `Usage: ${lines.join("\n ")}`;
28
29const FROM_SPEC = `from-spec SPEC.tla
30 Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state
31 breaks, and the separate runs find the rest. Each run also has the rule as a CONSTRAINT, so TLC
32 stops each path at its first state that breaks the rule, and a rule that stays broken reports
33 only the step that broke it. SPEC.cfg has SPECIFICATION and one INVARIANT line per rule. Each
34 PROPERTY, a rule about whole behaviors, gets one TLC run too, and the charts do not draw it. A
35 rule listed as both INVARIANT and PROPERTY is checked once, as an INVARIANT. One
36 more run dumps the state graph, and then the command draws the charts. TLC names each step after
37 the innermost named action that takes it, so give each kind of step its own named action. The
38 command warns when steps carry the name of the whole next-state action. The command prints the
39 config that it checks each model with, and stops when a config does not exist, with the configs
40 beside the spec.
41
42 --config FILE The config for SPEC.tla. Default: SPEC.cfg next to the spec.
43 --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and
44 module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks
45 it the same way, with FIXED.cfg, or with the config of SPEC.tla when there is
46 no FIXED.cfg. The charts then mark each bad state fixed or still wrong.
47 --fixed-config FILE
48 The config for FIXED.tla.
49 --against AGREED.tla
50 The spec that SPEC.tla must follow, when SPEC.tla models the code. It needs
51 its own file and module name. The command checks it with AGREED.cfg, and
52 never with the config of SPEC.tla. SPEC.tla may add variables, record fields,
53 and actions that AGREED.tla does not have. The comparison leaves them out.
54 Do not use it with --fixed.
55 --against-config FILE
56 The config for AGREED.tla, when it is not AGREED.cfg. Give the config that the
57 spec's authors say the unchanged spec passes. A rule that this config does not
58 check is "not checked" in the stories.
59 --label TEXT The name of the --fixed or --against model in the charts and the stories.
60 Default: "the fix", or "the spec".
61 --map FILE The labels for each model state and step. Default: SPEC.map.mjs next to the
62 spec. When the file does not exist, the command writes one from the spec's
63 VARIABLES and actions to fill in, and exits 2 before it runs TLC. Its
64 comments say what each function gets.
65
66 With --fixed or --against, SPEC.tla (and FIXED.tla) models the code, and the command finds each
67 condition of an action that no comment ties to code with a file and line, as in \\* pay.ts:120.
68 A condition is a conjunct of the action that changes nothing and reads a variable that the
69 action does not change. A rule can hold only because the model waits for a condition that the
70 code does not check, so the command prints these conditions when a rule holds.
71 With --against, each story says whether a config beside AGREED.tla expects TLC to break the
72 bug's rule. A config's "\\* expect: RULE" line says what TLC finds, and "\\* module: NAME" names
73 the module it checks when the folder has more than one .tla file. The command warns when no
74 config beside AGREED.tla has an expect line for that module.`;
75
76const FROM_CHART = `from-chart CHART.mmd
77 Writes NAME.tla, NAME.cfg, and NAME.map.mjs next to the chart. When NAME.tla already exists, it
78 writes none of them. Then it checks the spec as from-spec does, and compares the chart that the
79 spec draws with CHART.mmd.
80
81 --module NAME The TLA+ module name. Default: the chart's file name in CamelCase, such as
82 NotesApp for notes-app.mmd.
83
84 The chart is a Mermaid stateDiagram-v2. Each transition label is one action. For parallel
85 regions, split one top-level composite with --, and put each part in a named composite, such as
86 state Connection { ... }. Each region is one variable. A step moves every region whose current
87 state has a transition with the step's label, and the other regions stay where they are. Each
88 rule is one comment line:
89 %% never NAME: A & B -- the rule in one sentence
90 Each name after the colon is a state caption or a composite, and each comes from a different
91 region. A composite stands for every state in it. The command refuses final, choice, fork, and
92 history states, and regions below the top level.`;
93
94const COMMON = `Both commands
95 --expect-broken RULE
96 An INVARIANT or PROPERTY that is known to break. Repeat it, or list rules
97 with commas, as in A,B. The command then exits 0 when exactly these rules
98 break, and the --fixed or --against model breaks no other rule.
99 --title TEXT The chart title. Each bug chart adds ", bug N".
100 --out DIR The output folder. Default: NAME-statechart next to the input. A run writes its
101 files in a temporary folder, and then replaces the whole output folder with
102 it. When runs overlap, the output folder has the files of the run that
103 finished last. The command does not use an output folder that has files that
104 it did not write. On a filesystem that cannot rename, the command copies the
105 files into place, and overlapping runs can mix their files.
106 --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli.
107 -h, --help Print this help, and exit 0.
108
109Output
110 The command prints whether each rule holds, the chart's counts, and one story per bug.
111 from-chart also prints the comparison. The command writes these files in the output folder:
112 chart.mmd The overview.
113 bug-N.mmd Only the steps of bug N, numbered. Bugs are numbered by fewest reader steps,
114 then fewest model steps, then rule name, then cause. Bugs that only the
115 --fixed or --against model has come last.
116 bug-N-developer.mmd, bug-N-layered.mmd
117 The path of bug N through the developer's states, when the mapping exports
118 detail().
119 stories.md One entry per bug: the rule, why it breaks, the shortest path in reader
120 steps, and what the --fixed or --against model does instead.
121 summary.json The counts behind the chart. With --fixed or --against, uncited has the
122 conditions with no file and line for each code model. With --against,
123 second.listed has what each config beside AGREED.tla expects, and each bug
124 has the configs that list it.
125 tlc/ The config and the TLC output of each run. When the overview has regions,
126 from-spec also writes tlc/regions-all.mmd, the regions with every arrow, and
127 tlc/regions-proof.mmd, every state and step of TLC's graph in one region.
128 compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the
129 proof.
130 *.png Each chart as a PNG, with --png. A run without --png deletes them.
131
132Reading a chart
133 from-spec draws the overview as two parallel regions when every state reads "context: progress":
134 the contexts on one side, the progress on the other, both at the same time. It does so only when
135 that has fewer states, and when the regions combine back into exactly every state and step of
136 TLC's graph. Otherwise it puts the states of one context in a box named context, and prints why.
137 Regions draw only the arrows of the shortest way to each state, of each bug path, and of the
138 nearest step with each label. The script checks that these arrows make no step that TLC's graph
139 does not have.
140 An arrow from a box's border leaves from every state in the box.
141 In regions, a step leaves every region that has it. "[when A]" or "[unless A]" says which states
142 of the other region the step needs. A loop says the region may also stay.
143 Other words in [ ] come from the mapping's guard(), and "after N s" from its timer(). TLC does
144 not check them, and has no clock, so a reviewer checks them against the code.
145 Gray states work. Red states break a rule. With --fixed, red states stay wrong with the fix, and
146 green states are fixed by it or exist only with it. With --against, red states break a rule in
147 either model, and green states exist only in the spec.
148 When the mapping exports group(), the fill is the state's group, which the legend names, and the
149 status is a thick red or green border on top. tlc-to-chart.mjs --help tells more.
150 A state is bad when it breaks a rule, or when every path from the start to it passes a state
151 that breaks one. TLC stops each path at the first state that breaks the rule, so a rule that
152 stays broken colors only the states where it first breaks and the states after them that no
153 path without a bug reaches.
154 A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an
155 edge.
156 A label "with the fix, STEP" or "in the spec, STEP" is a step that only the other model takes. On
157 a bug's path it leaves the state before the first step that the other model shows the reader
158 differently, and ends where that model goes instead. "with the fix, STEP cannot happen" or "in
159 the spec, STEP cannot happen" loops on the state where that model stops the step. Outside
160 regions, two states have at most one arrow between them. Its lines are the numbered steps, then
161 SPEC.tla's steps, then the other model's steps, with a blank line between each group.
162 An edge lists every step that takes it, most common first. Words that the steps share at the
163 start are written once, as in "you tap Pay or Back".
164
165Regenerate
166 With --expect-broken, one script rebuilds every chart and stops on any change:
167 set -e
168 node statechart.mjs from-spec Code.tla --against Agreed.tla --expect-broken NoLostUpload --png
169
170TLC
171 TLA2TOOLS is the path to tla2tools.jar, and JAVA is the java binary (default: java, Java 11 or
172 later). Download tla2tools.jar from https://github.com/tlaplus/tlaplus/releases. When TLA2TOOLS
173 is not set, the command says where to get TLC and exits 2.
174 TLC checks each rule with one worker per CPU. When a rule is broken, TLC checks it again with one
175 worker, which finds the shortest bug paths, the same on every run. The command then prints how
176 many times TLC ran and how long that took.
177 TLC can find that a rule is broken and then fail with a Java exception while it reports the rule.
178 The command then keeps the paths that TLC printed, marks the rule broken, and warns that the
179 charts can miss states that break it. The failure is in TLC, so do not weaken the rule because of
180 it. A smaller constant can let TLC finish.
181
182Exit codes
183 0 Every rule holds, or exactly the --expect-broken rules break. from-chart also draws CHART.mmd
184 back unchanged.
185 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not
186 CHART.mmd. The output says which.
187 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it
188 cannot read, no TLC, a helper script that cannot start, an output folder with files that the
189 command did not write, an input file that changed during the run, a new mapping to fill in,
190 or a chart that --png cannot render. When a chart does not render, the command keeps the
191 files it wrote and exits 2, whatever the rules give. Otherwise, exit 2 does not change the
192 output folder, unless the command moved it aside and cannot put it back. Then the message
193 names the folder's new path. When TLC stops on an error, the message names TLC's output, in a
194 temporary folder that the command keeps. When the command cannot put its files in the output
195 folder, it keeps them in a temporary folder, and the message names it. The command checks
196 its input files again before --png renders, so a changed input stops the run before that.`;
197
198const HELP = {
199 "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])}
200
201Draws a Mermaid statechart of what an app's user sees, from TLC's state graph of a TLA+ spec. The
202help calls that user the reader.
203
204${FROM_SPEC}
205
206${FROM_CHART}
207
208${COMMON}`,
209 "from-spec": `${usage([USAGE["from-spec"]])}\n\n${FROM_SPEC}\n\n${COMMON}`,
210 "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`,
211};
212
213const more = (cmd) =>
214 `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`;
215
216class CannotFinish extends Error {}
217class TlcStopped extends CannotFinish {}
218class NotPublished extends CannotFinish {}
219
220const COMMANDS = {
221 "from-spec": {
222 flags: [
223 "--map",
224 "--config",
225 "--fixed",
226 "--fixed-config",
227 "--against",
228 "--against-config",
229 "--label",
230 "--expect-broken",
231 "--title",
232 "--out",
233 ],
234 run: fromSpec,
235 },
236 "from-chart": { flags: ["--module", "--expect-broken", "--title", "--out"], run: fromChart },
237};
238
239function parseArgs([command, ...rest]) {
240 if (command === "-h" || command === "--help") return { help: HELP[""] };
241 if (!Object.hasOwn(COMMANDS, command))
242 throw new CannotFinish(`${usage(Object.values(USAGE))}\n${more()}`);
243 const flags = { "expect-broken": new Set() };
244 let input;
245 for (let i = 0; i < rest.length; i++) {
246 const a = rest[i];
247 if (a === "-h" || a === "--help") return { help: HELP[command] };
248 if (a === "--png") flags.png = true;
249 else if (COMMANDS[command].flags.includes(a)) {
250 if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`);
251 const name = a.slice(2);
252 if (flags[name] instanceof Set) for (const v of rest[++i].split(",")) flags[name].add(v);
253 else flags[name] = rest[++i];
254 } else if (!a.startsWith("-") && input === undefined) input = a;
255 else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`);
256 }
257 if (input === undefined) throw new CannotFinish(`${usage([USAGE[command]])}\n${more(command)}`);
258 return { command, input, flags };
259}
260
261const rel = (file) => {
262 const r = path.relative(process.cwd(), file) || ".";
263 return r === ".." || r.startsWith(`..${path.sep}`) ? path.resolve(file) : r;
264};
265const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg");
266
267function findTools() {
268 try {
269 return findTlc();
270 } catch (e) {
271 throw new CannotFinish(e.message);
272 }
273}
274
275function script(name, scriptArgs, about, { okExits = [0] } = {}) {
276 const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], {
277 encoding: "utf8",
278 });
279 if (r.error) throw new CannotFinish(`cannot run ${name}: ${r.error.message}`);
280 if (okExits.includes(r.status)) return r;
281 const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1];
282 throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim());
283}
284
285const KEYWORD =
286 /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/;
287
288function splitRules(cfg) {
289 const words = cfg
290 .replace(/\(\*[^]*?\*\)/g, " ")
291 .replace(/\\\*.*/g, " ")
292 .split(/\s+/)
293 .filter(Boolean);
294 const kept = [];
295 const rules = [];
296 const properties = [];
297 let section = "";
298 for (const w of words) {
299 if (KEYWORD.test(w)) section = w;
300 const list = /^INVARIANTS?$/.test(section)
301 ? rules
302 : /^PROPERT(Y|IES)$/.test(section)
303 ? properties
304 : null;
305 if (!list) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w);
306 else if (w !== section && !list.includes(w)) list.push(w);
307 }
308 // TLC takes only a state predicate as an INVARIANT, and checks a state predicate given as a
309 // PROPERTY only in the initial states, which the INVARIANT run checks too.
310 return {
311 cfgWithoutRules: `${kept.join(" ")}\n`,
312 rules,
313 properties: properties.filter((p) => !rules.includes(p)),
314 };
315}
316
317function tlc(tools, spec, cfg, base, { dump, workers = "1" } = {}) {
318 fs.writeFileSync(`${base}.cfg`, cfg);
319 const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-"));
320 // TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names,
321 // so runs that share that folder can read a module while another run rewrites it.
322 const javaTmp = path.join(metadir, "tmp");
323 fs.mkdirSync(javaTmp);
324 // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock.
325 const r = spawnSync(
326 tools.java,
327 [
328 "-XX:+UseParallelGC",
329 `-Djava.io.tmpdir=${javaTmp}`,
330 "-cp",
331 tools.jar,
332 "tlc2.TLC",
333 "-workers",
334 workers,
335 "-metadir",
336 metadir,
337 "-deadlock",
338 "-continue",
339 ...(dump ? ["-dump", "dot,actionlabels", path.resolve(dump)] : []),
340 "-config",
341 path.resolve(`${base}.cfg`),
342 path.basename(spec),
343 ],
344 { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 },
345 );
346 fs.rmSync(metadir, { recursive: true, force: true });
347 if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`);
348 fs.writeFileSync(`${base}.out`, r.stdout);
349 try {
350 return { stdout: r.stdout, warning: tlcOutputWarning(r.stdout, `${base}.out`) };
351 } catch (e) {
352 throw new TlcStopped(e.message);
353 }
354}
355
356const withoutComments = (spec) =>
357 fs
358 .readFileSync(spec, "utf8")
359 .replace(/\(\*[^]*?\*\)/g, " ")
360 .replace(/\\\*.*/g, " ");
361
362function warnUnnamedSteps(spec, cfg, dot) {
363 const text = withoutComments(spec);
364 const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1];
365 const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text);
366 const next =
367 /^NEXT\s+(\w+)/m.exec(cfg)?.[1] ?? /\[\]\s*\[\s*(\w+)\s*\]_/.exec(specBody?.[1] ?? text)?.[1];
368 const graph = parseDot(fs.readFileSync(dot, "utf8"));
369 const unnamed = [...graph.edges, ...graph.loops].filter((e) => e.action === next).length;
370 if (unnamed > 0)
371 console.error(
372 `${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.`,
373 );
374}
375
376function readModel(spec, cfgFile, flag) {
377 if (!fs.existsSync(spec)) throw new CannotFinish(`${rel(spec)} does not exist`);
378 if (!fs.existsSync(cfgFile)) {
379 const beside = fs
380 .readdirSync(path.dirname(spec))
381 .filter((f) => f.endsWith(".cfg"))
382 .sort();
383 throw new CannotFinish(
384 `${rel(cfgFile)} does not exist. Pass ${flag} with the config to check ${path.basename(spec)} with. ${
385 beside.length
386 ? `The configs beside it: ${beside.join(", ")}.`
387 : "A config has SPECIFICATION and one INVARIANT line per rule."
388 }`,
389 );
390 }
391 return { spec, cfgFile, ...splitRules(fs.readFileSync(cfgFile, "utf8")) };
392}
393
394function readInput(spec, cfgFile, expected) {
395 const model = readModel(spec, cfgFile, "--config");
396 const names = [...model.rules, ...model.properties];
397 const unknown = [...expected].filter((r) => !names.includes(r));
398 if (unknown.length)
399 throw new CannotFinish(
400 `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${names.join(", ") || "none"}.`,
401 );
402 return model;
403}
404
405function check(tools, model, { work, out }, expected) {
406 const { spec, cfgFile, cfgWithoutRules, rules, properties } = model;
407 const started = Date.now();
408 let runs = 0;
409 const run = (cfg, file, options) => {
410 runs++;
411 return tlc(tools, spec, cfg, file, options);
412 };
413 const name = path.basename(spec, ".tla");
414 const tlcDir = path.join(work, "tlc");
415 const dot = path.join(tlcDir, `${name}.dot`);
416 // One worker writes the graph in a fixed order, which breaks ties between equally short paths.
417 run(cfgWithoutRules, path.join(tlcDir, name), { dump: dot });
418 warnUnnamedSteps(spec, cfgWithoutRules, dot);
419 console.log(`TLC on ${path.basename(spec)} with ${rel(cfgFile)}:`);
420 if (rules.length === 0)
421 console.log(
422 ` ${path.basename(cfgFile)} has no INVARIANT, so ${properties.length ? "the chart marks no state broken" : "no rule is checked"}.`,
423 );
424 const said = (rule, broken) =>
425 ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`;
426 const shown = (file) => rel(path.join(out, "tlc", path.basename(file)));
427 const traces = rules.map((rule) => {
428 const file = path.join(tlcDir, `${name}-${rule}`);
429 const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`;
430 let result = run(cfg, file, { workers: "auto" });
431 // Several workers can reach a bad state by a longer path first. One worker searches breadth
432 // first in a fixed order, so the chart's bug paths are the shortest, and the same every run.
433 if (/^Error: Invariant /m.test(result.stdout)) result = run(cfg, file);
434 const broken = /^Error: Invariant /m.test(result.stdout);
435 console.log(said(rule, broken));
436 if (result.warning) console.error(`${shown(`${file}.out`)}: ${result.warning}`);
437 return { file: `${file}.out`, rule, broken };
438 });
439 const temporal = properties.map((property) => {
440 const file = path.join(tlcDir, `${name}-${property}`);
441 const { stdout } = run(`${cfgWithoutRules}PROPERTY ${property}\n`, file, { workers: "auto" });
442 const broken = BROKEN_PROPERTY.test(stdout);
443 console.log(
444 `${said(property, broken)}${broken ? ` The chart draws only INVARIANT rules, so read TLC's counterexample in ${shown(`${file}.out`)}.` : ""}`,
445 );
446 return { file: `${file}.out`, rule: property, broken };
447 });
448 const seconds = ((Date.now() - started) / 1000).toFixed(1);
449 console.log(` TLC ran ${runs} ${runs === 1 ? "time" : "times"} in ${seconds} s.`);
450 const broken = [...traces, ...temporal].some((t) => t.broken);
451 return { spec, dot, traces, properties: temporal, broken };
452}
453
454function rulesAsExpected(now, other, expected) {
455 const brokenIn = (m) =>
456 [...(m?.traces ?? []), ...(m?.properties ?? [])].filter((t) => t.broken).map((t) => t.rule);
457 if (expected.size === 0) return !now.broken && !other?.broken;
458 const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter(
459 (r) => !expected.has(r),
460 );
461 const holds = [...expected].filter((r) => !brokenIn(now).includes(r));
462 if (unexpected.length) console.log(`\nBroken but not expected: ${unexpected.join(", ")}.`);
463 if (holds.length) console.log(`\nExpected to break but holds: ${holds.join(", ")}.`);
464 if (!unexpected.length && !holds.length)
465 console.log(`\nBroken as expected: ${[...expected].join(", ")}.`);
466 return !unexpected.length && !holds.length;
467}
468
469const SCAFFOLD = (name, spec) => {
470 const text = withoutComments(spec);
471 const vars = tlaVariables(text);
472 // A definition that only another action calls is listed too, though TLC names no step after it.
473 const actions = [
474 ...new Set(
475 text
476 .split(/\n(?=\S)/)
477 .map((d) => /^(\w+)\s*(?:\([^)]*\))?\s*==([^]*)/.exec(d))
478 .filter((d) => d && PRIMES_OR_UNCHANGED.test(d[2]))
479 .map((d) => d[1]),
480 ),
481 ].sort();
482 return `// Chart labels for ${name}, for statechart.mjs. Write labels in plain words, not variable or action names.
483// v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind.
484// Sequences, tuples, and sets become arrays, indexed from 0. Records and functions become objects.
485// Variables: ${vars.join(", ")}.
486
487// The actions the reader takes. facts.settled is true when only these are enabled, and the overview
488// keeps the shortest path to each settled state, plus every bug path.
489export const readerActions = [];
490
491// The label for one model state. facts.enabled is the set of actions the state enables,
492// including steps that change no variable. Write it as "context: progress", such as
493// "Offline: upload queued": the chart puts the states of one context in one box.
494// Do not claim a result that a pending step can still change, such as "upload failed" while a
495// retry is still queued.
496export function state(v, facts) {
497 return JSON.stringify(v);
498}
499
500// The names of the two parallel regions, when the overview draws them: contexts, then progress.
501// export const regionNames = ["Network", "What happened to the file"];
502
503// The group of one model state, in words the reader sees, such as "Upload done". The chart fills
504// the states of one group with one color, and the legend names the groups. TLC still decides
505// which states break a rule, and draws them with a thick red border on top of the group color.
506// groupColors gives a group a light color. Groups without one take a palette color.
507// export function group(v, facts) {}
508// export const groupColors = { "Upload done": "#d1f2eb" };
509
510// The label for one step. A step that leaves the reader in the same state is not drawn,
511// unless the reader takes it on a bug path. "" draws no label, and a numbered bug step shows the action.
512const STEPS = {
513${actions.map((a) => `\t${a}: ${JSON.stringify(a)},`).join("\n")}
514};
515export function step(action, before, after) {
516 return STEPS[action] ?? action;
517}
518
519// When the step can happen, in plain words, such as "offline or disk full". The chart shows
520// "step [words]". "" adds nothing.
521// export function guard(action, before, after) {}
522
523// For a timer step, how many seconds the code waits, from the constant in the code. The chart
524// shows "after N s, step". Return nothing for other steps.
525// export function timer(action, before, after) {}
526
527// Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and
528// the app drops an upload it cannot send". No variable, function, or event names.
529// Counterexamples that break one rule with one cause are one bug, so two bugs that end on the
530// same screen stay apart only if their causes differ. Return null to group by the end state.
531// export function cause(v, facts, invariant) {}
532
533// The developer's words for one model state. With it, each bug is also drawn through these states.
534// export function detail(v, facts) {}
535
536// The legend's words for each status, in place of the defaults. Group entries keep their names.
537// export const legend = { works: "Works on main", wrong: "Wrong on main", fixed: "Fixed in the open PR" };
538`;
539};
540
541async function draw({ now, other, uncited, map, work, out, title, comparing }) {
542 const shown = published(work, out);
543 const mapping = await import(pathToFileURL(path.resolve(map)).href);
544 const from = ["--dot", now.dot, "--map", map, "--spec", now.spec];
545 from.push(...now.traces.flatMap((t) => ["--traces", t.file]));
546 if (other) {
547 from.push(`--${other.role}-dot`, other.dot, "--spec", other.spec);
548 from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file]));
549 if (other.traces.length)
550 from.push(`--${other.role}-rules`, other.traces.map((t) => t.rule).join(","));
551 if (other.label !== undefined) from.push("--label", other.label);
552 if (other.listed) from.push("--listed", JSON.stringify(other.listed));
553 }
554 if (comparing) from.push("--full-labels", "--flat");
555 else if (mapping.readerActions?.length) from.push("--cover");
556 const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []);
557
558 const files = {
559 overview: path.join(work, "chart.mmd"),
560 stories: path.join(work, "stories.md"),
561 summary: path.join(work, "summary.json"),
562 };
563 const proof = path.join(work, "tlc", "regions-proof.mmd");
564 const everyArrow = path.join(work, "tlc", "regions-all.mmd");
565 script(
566 "tlc-to-chart.mjs",
567 [
568 ...from,
569 ...(comparing ? [] : ["--regions", "--proof", proof, "--regions-all", everyArrow]),
570 ...titled(""),
571 "--highlight",
572 "0",
573 "--out",
574 files.overview,
575 "--story",
576 files.stories,
577 "--summary",
578 files.summary,
579 ],
580 map,
581 );
582 const summary = JSON.parse(fs.readFileSync(files.summary, "utf8"));
583 if (uncited)
584 fs.writeFileSync(files.summary, `${JSON.stringify({ ...summary, uncited }, null, 2)}\n`);
585 const charts = [files.overview];
586 for (const c of summary.classes) {
587 const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""];
588 for (const view of views) {
589 const file = path.join(work, `bug-${c.n}${view}.mmd`);
590 const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"];
591 script(
592 "tlc-to-chart.mjs",
593 [...from, ...shape, ...titled(`, bug ${c.n}`), "--highlight", String(c.n), "--out", file],
594 map,
595 );
596 charts.push(file);
597 }
598 }
599
600 const { works, wrong, fixed: fixedCount } = summary.chartStatus;
601 const L = summary.second?.label;
602 console.log(
603 `\nChart: ${summary.chartStates} states and ${summary.chartTransitions} transitions from ${summary.modelStates} model states.`,
604 );
605 const r = summary.regions;
606 if (r?.drawn)
607 console.log(
608 `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.`,
609 );
610 else if (r) console.log(`It has no parallel regions, because ${r.why}.`);
611 const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`;
612 console.log(
613 other?.role === "fixed"
614 ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n`
615 : other?.role === "against"
616 ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n`
617 : `${works} work, ${breaks}.\n`,
618 );
619 if (summary.codeLike.length)
620 console.log(
621 `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`,
622 );
623 console.log(
624 summary.classes.length
625 ? fs.readFileSync(files.stories, "utf8").trimEnd()
626 : "No state breaks a rule.",
627 );
628 return { files, charts };
629}
630
631function render(charts, shown) {
632 const pngs = [];
633 for (const chart of charts) {
634 const png = chart.replace(/\.mmd$/, ".png");
635 const r = spawnSync(
636 "npx",
637 ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2"],
638 {
639 encoding: "utf8",
640 },
641 );
642 if (r.status === 0) pngs.push(png);
643 else
644 console.error(
645 `could not render ${shown(chart)}: ${(r.stderr || r.error?.message || "").trim()}`,
646 );
647 }
648 return pngs;
649}
650
651function finish({ work, out, inputs }, { files, charts }, ok, png) {
652 unchanged(inputs, out);
653 const pngs = png ? render(charts, published(work, out)) : [];
654 publish(work, out, inputs);
655 const written = [...charts, ...pngs, files.stories, files.summary]
656 .map((f) => path.basename(f))
657 .join(", ");
658 console.log(`\nWrote ${written} in ${rel(out)}.`);
659 if (png && pngs.length < charts.length) return 2;
660 return ok ? 0 : 1;
661}
662
663const published = (work, out) => (file) => rel(path.join(out, path.relative(work, file)));
664
665const OWN =
666 /^((chart|bug-\d+(-developer|-layered)?)\.(mmd|png)|stories\.md|summary\.json|tlc|\.DS_Store)$/;
667
668function foreign(dir) {
669 try {
670 return fs
671 .readdirSync(dir)
672 .filter((f) => !OWN.test(f))
673 .sort();
674 } catch (e) {
675 if (e.code === "ENOENT") return [];
676 throw new CannotFinish(`cannot use ${rel(dir)} as the output folder: ${e.message}`);
677 }
678}
679
680function refuseForeign(out, files = foreign(out)) {
681 const [first, ...others] = files;
682 if (first)
683 throw new CannotFinish(
684 `${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.`,
685 );
686}
687
688function outDir(flagged, input, name) {
689 const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`);
690 refuseForeign(out);
691 return out;
692}
693
694const fingerprint = (file) => {
695 try {
696 const { ino, ctimeNs } = fs.statSync(file, { bigint: true });
697 return `${ino} ${ctimeNs} ${fs.readFileSync(file, "base64")}`;
698 } catch {
699 return null;
700 }
701};
702
703function snapshot(files, inputs = new Map()) {
704 for (const f of files) if (f && !inputs.has(f)) inputs.set(f, fingerprint(f));
705 return inputs;
706}
707
708async function inWorkFolder(body) {
709 const work = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-"));
710 fs.mkdirSync(path.join(work, "tlc"));
711 let keep = false;
712 try {
713 return await body(work);
714 } catch (e) {
715 keep = e instanceof TlcStopped || e instanceof NotPublished;
716 if (e instanceof NotPublished)
717 e.message += ` The files that this run wrote are in ${rel(work)}.`;
718 throw e;
719 } finally {
720 if (!keep) fs.rmSync(work, { recursive: true, force: true });
721 }
722}
723
724const moved = (from, to, ...missed) => {
725 try {
726 fs.renameSync(from, to);
727 return true;
728 } catch (e) {
729 if (missed.includes(e.code)) return false;
730 if (!["ENOSYS", "EXDEV", "EISDIR"].includes(e.code)) throw e;
731 }
732 // Some filesystems, such as FUSE mounts, cannot rename a folder that has files. They fail with
733 // ENOSYS, or with EISDIR when the target exists.
734 if (!fs.existsSync(from) && missed.includes("ENOENT")) return false;
735 if (fs.existsSync(to)) {
736 if (fs.readdirSync(to).length && missed.includes("ENOTEMPTY")) return false;
737 fs.rmdirSync(to);
738 }
739 try {
740 fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
741 } catch (e) {
742 fs.rmSync(to, { recursive: true, force: true });
743 throw e;
744 }
745 fs.rmSync(from, { recursive: true, force: true });
746 return true;
747};
748
749function unchanged(inputs, out) {
750 const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0];
751 if (changed)
752 throw new NotPublished(
753 `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`,
754 );
755}
756
757function publish(work, out, inputs) {
758 const parent = path.dirname(path.resolve(out));
759 fs.mkdirSync(parent, { recursive: true });
760 const next = fs.mkdtempSync(path.join(parent, `.${path.basename(out)}-`));
761 const old = `${next}-old`;
762 let aside = false;
763 try {
764 fs.cpSync(work, next, { recursive: true });
765 unchanged(inputs, out);
766 refuseForeign(out);
767 while (!moved(next, out, "ENOTEMPTY", "EEXIST")) {
768 if (aside) {
769 refuseForeign(out);
770 fs.rmSync(old, { recursive: true, force: true });
771 aside = false;
772 }
773 aside = moved(out, old, "ENOENT");
774 if (aside) refuseForeign(out, foreign(old));
775 }
776 } catch (e) {
777 const back = !aside || putBack(old, out);
778 fs.rmSync(next, { recursive: true, force: true });
779 const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`;
780 if (!back)
781 throw new NotPublished(
782 `${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`,
783 );
784 throw new NotPublished(
785 e instanceof CannotFinish ? why : `${why} The run left ${rel(out)} as it was.`,
786 );
787 }
788 if (aside) fs.rmSync(old, { recursive: true, force: true });
789}
790
791function putBack(old, out) {
792 try {
793 return moved(old, out, "ENOTEMPTY", "EEXIST");
794 } catch {
795 return false;
796 }
797}
798
799function secondCfg(cfg, flags) {
800 if (flags["fixed-config"] && !flags.fixed)
801 throw new CannotFinish(`--fixed-config needs --fixed. ${more("from-spec")}`);
802 if (flags["against-config"] && !flags.against)
803 throw new CannotFinish(`--against-config needs --against. ${more("from-spec")}`);
804 if (flags.fixed)
805 return flags["fixed-config"] ?? (fs.existsSync(cfgOf(flags.fixed)) ? cfgOf(flags.fixed) : cfg);
806 return flags.against && (flags["against-config"] ?? cfgOf(flags.against));
807}
808
809function secondModel(input, cfg, { fixed, against, label }) {
810 if (fixed && against)
811 throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`);
812 if (!fixed && !against) {
813 if (label === undefined) return null;
814 throw new CannotFinish(
815 `--label names the --fixed or --against model; it needs one of them. ${more("from-spec")}`,
816 );
817 }
818 const [role, spec] = fixed ? ["fixed", fixed] : ["against", against];
819 const name = path.basename(input, ".tla");
820 if (path.basename(spec, ".tla") === name)
821 throw new CannotFinish(
822 `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`,
823 );
824 return { role, label, model: readModel(spec, cfg, `--${role}-config`) };
825}
826
827function warnUnchecked(now, other) {
828 const unchecked = now.rules.filter((r) => !other.rules.includes(r));
829 if (unchecked.length)
830 console.error(
831 `${rel(other.cfgFile)} does not check ${unchecked.join(", ")}, so the stories cannot say whether ${path.basename(other.spec)} keeps ${unchecked.length === 1 ? "it" : "them"}.`,
832 );
833}
834
835function listedBreaks(spec) {
836 const dir = path.dirname(spec);
837 const files = fs.readdirSync(dir).sort();
838 const modules = files.filter((f) => f.endsWith(".tla") && !f.includes("_TTrace_"));
839 const only = modules.length === 1 ? path.basename(modules[0], ".tla") : undefined;
840 const breaks = {};
841 let headed = false;
842 for (const f of files.filter((f) => f.endsWith(".cfg"))) {
843 const cfg = fs.readFileSync(path.join(dir, f), "utf8");
844 const module = /^\\\* module:[ \t]*(\w+)[ \t]*$/m.exec(cfg)?.[1] ?? only;
845 const expect = /^\\\* expect:[ \t]*(\w+)[ \t]*$/m.exec(cfg)?.[1];
846 if (module !== path.basename(spec, ".tla") || !expect) continue;
847 headed = true;
848 if (!/^(pass|deadlock)$|^error/.test(expect)) breaks[f] = expect;
849 }
850 return headed ? { spec: path.basename(spec), breaks } : null;
851}
852
853function warnUnlisted(now, other) {
854 if (other.listed || !now.traces.some((t) => t.broken)) return;
855 const module = path.basename(other.spec, ".tla");
856 console.error(
857 `No config beside ${rel(other.spec)} has an "\\* expect:" line for ${module}, so the stories cannot say whether ${module} already lists each bug. When ${path.basename(other.spec)} is a copy, pass the spec in its own folder.`,
858 );
859}
860
861function warnUncited(models) {
862 return Object.fromEntries(
863 models.map(({ spec, traces, properties }) => {
864 const found = uncitedConditions(fs.readFileSync(spec, "utf8"));
865 const held = [...traces, ...properties].filter((t) => !t.broken).map((t) => t.rule);
866 const short = (s) => (s.length > 90 ? `${s.slice(0, 87)}...` : s);
867 if (found.length && held.length)
868 console.error(
869 `${rel(spec)}: ${joinAnd(held)} ${held.length === 1 ? "holds" : "hold"}, but a rule can also hold because the model waits for a condition that the code does not check. No comment gives the file and line of the code that checks ${found.length === 1 ? "this condition" : "these conditions"}:\n${found.map((c) => ` ${rel(spec)}:${c.line} ${c.action}: ${short(c.condition)}`).join("\n")}\nPut that file and line in a comment on each condition, as in \\* pay.ts:120, or remove the condition when no code checks it.`,
870 );
871 return [path.basename(spec), found];
872 }),
873 );
874}
875
876async function fromSpec(input, { "expect-broken": expected, ...flags }) {
877 const name = path.basename(input, ".tla");
878 const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`);
879 const cfg = flags.config ?? cfgOf(input);
880 const otherCfg = secondCfg(cfg, flags);
881 const inputs = snapshot([input, cfg, map, flags.fixed ?? flags.against, otherCfg]);
882 const second = secondModel(input, otherCfg, flags);
883 const model = readInput(input, cfg, expected);
884 const out = outDir(flags.out, input, name);
885 if (!fs.existsSync(map)) {
886 fs.writeFileSync(map, SCAFFOLD(name, input));
887 throw new CannotFinish(`Wrote ${rel(map)}. Fill in its labels, then run this command again.`);
888 }
889 const tools = findTools();
890 return inWorkFolder(async (work) => {
891 const now = check(tools, model, { work, out }, expected);
892 const other = second && {
893 role: second.role,
894 label: second.label,
895 listed: second.role === "against" ? listedBreaks(second.model.spec) : undefined,
896 ...check(tools, second.model, { work, out }, expected),
897 };
898 let uncited;
899 if (second) {
900 warnUnchecked(model, second.model);
901 if (second.role === "against") warnUnlisted(now, other);
902 uncited = warnUncited(second.role === "fixed" ? [now, other] : [now]);
903 }
904 const drawn = await draw({ now, other, uncited, map, work, out, title: flags.title });
905 return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), flags.png);
906 });
907}
908
909async function fromChart(input, { "expect-broken": expected, ...flags }) {
910 const name =
911 flags.module ??
912 path
913 .basename(input, path.extname(input))
914 .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase());
915 if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name))
916 throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`);
917 const spec = path.join(path.dirname(input), `${name}.tla`);
918 const map = spec.replace(/\.tla$/, ".map.mjs");
919 const inputs = snapshot([input]);
920 if (fs.existsSync(spec)) {
921 console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`);
922 } else {
923 script("chart-to-tla.mjs", [input, "--module", name], input);
924 console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`);
925 }
926 snapshot([spec, cfgOf(spec), map], inputs);
927 const tools = findTools();
928 const model = readInput(spec, cfgOf(spec), expected);
929 const out = outDir(flags.out, input, name);
930 return inWorkFolder(async (work) => {
931 const now = check(tools, model, { work, out }, expected);
932 const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true });
933 const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, {
934 okExits: [0, 1],
935 });
936 if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`);
937 else
938 console.log(
939 `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`,
940 );
941 return finish(
942 { work, out, inputs },
943 drawn,
944 rulesAsExpected(now, null, expected) && compared.status === 0,
945 flags.png,
946 );
947 });
948}
949
950try {
951 const { help, command, input, flags } = parseArgs(process.argv.slice(2));
952 if (help) {
953 console.log(help);
954 process.exit(0);
955 }
956 process.exit(await COMMANDS[command].run(input, flags));
957} catch (e) {
958 if (!(e instanceof CannotFinish)) throw e;
959 console.error(e.message);
960 process.exit(2);
961}
skills/statechart/scripts/test/statechart.test.mjs+3291−0
@@ -0,0 +1,3291 @@
1import assert from "node:assert/strict";
2import { spawn, spawnSync } from "node:child_process";
3import {
4 existsSync,
5 mkdirSync,
6 mkdtempSync,
7 readdirSync,
8 readFileSync,
9 rmSync,
10 writeFileSync,
11} from "node:fs";
12import { tmpdir } from "node:os";
13import { delimiter, dirname, join } from "node:path";
14import { test } from "node:test";
15import { setTimeout as sleep } from "node:timers/promises";
16import { fileURLToPath } from "node:url";
17import {
18 findTlc,
19 parseDot,
20 parseMermaid,
21 parseTraces,
22 parseValue,
23 ruleSentences,
24 stateKey,
25 tlcOutputWarning,
26 uncitedConditions,
27} from "../lib.mjs";
28
29const here = dirname(fileURLToPath(import.meta.url));
30const scripts = dirname(here);
31const fixture = (name) => readFileSync(join(here, name), "utf8");
32const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort();
33const node = (script, args, cwd, env = {}) => {
34 const r = spawnSync(process.execPath, [join(scripts, script), ...args], {
35 cwd,
36 encoding: "utf8",
37 env: { ...process.env, ...env },
38 timeout: 120_000,
39 });
40 r.stdout = r.stdout?.replace(/^( {2}TLC ran \d+ times? in )\d+\.\d s\.$/gm, "$1N s.");
41 return r;
42};
43
44test("a transition on a composite leaves from every state inside it", () => {
45 const { regions } = parseMermaid(fixture("upload.mmd"));
46 const main = regions.Main;
47 assert.deepEqual([...main.states].sort(), [
48 "Nothing chosen",
49 "Upload failed",
50 "Uploaded",
51 "Uploading.Checking the file",
52 "Uploading.Sending the file",
53 ]);
54 assert.deepEqual([...main.initial], ["Nothing chosen"]);
55 assert.deepEqual(edges(main), [
56 "Nothing chosen -> Uploading.Sending the file : you choose a file",
57 "Upload failed -> Uploading.Sending the file : you retry",
58 "Uploading.Checking the file -> Nothing chosen : you cancel",
59 "Uploading.Checking the file -> Upload failed : the network drops",
60 "Uploading.Checking the file -> Uploaded : the check passes",
61 "Uploading.Sending the file -> Nothing chosen : you cancel",
62 "Uploading.Sending the file -> Upload failed : the network drops",
63 "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives",
64 ]);
65});
66
67test("the deepest source wins when a composite and a state inside it share a label", () => {
68 const { regions } = parseMermaid(`stateDiagram-v2
69 state Form {
70 [*] --> Typing
71 Typing --> Confirm : you press Escape
72 state Confirm
73 }
74 [*] --> Form
75 Form --> Closed : you press Escape
76 Confirm --> Closed : you press Escape`);
77 assert.deepEqual(edges(regions.Main), [
78 "Form.Confirm -> Closed : you press Escape",
79 "Form.Typing -> Form.Confirm : you press Escape",
80 ]);
81});
82
83test("a top-level composite split by -- makes one region per part", () => {
84 const { regions, nevers } = parseMermaid(fixture("sync.mmd"));
85 assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]);
86 assert.deepEqual(edges(regions["Sync badge"]), [
87 "Synced -> Waiting to sync : the network drops",
88 "Synced -> Waiting to sync : you edit a note",
89 "Waiting to sync -> Synced : the server confirms",
90 ]);
91 assert.deepEqual(nevers, [
92 {
93 name: "NeverSyncedWhileOffline",
94 states: ["Offline", "Synced"],
95 sentence: 'You never see "Synced" while you are offline.',
96 },
97 ]);
98});
99
100test("a rule's sentence is the comment lines right above it, and a state key ignores field order", () => {
101 const sentences = ruleSentences(`---- MODULE M ----
102\\* Not about a rule.
103
104\\* You never see "Saved"
105\\* while the note is a draft.
106NoFalseSaved == TRUE
107Bare == TRUE
108====`);
109 assert.deepEqual(
110 [...sentences],
111 [["NoFalseSaved", 'You never see "Saved" while the note is a draft.']],
112 );
113 assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 }));
114});
115
116test("an action's condition on a variable that the action does not change needs a file and line", () => {
117 const found = uncitedConditions(`---- MODULE Pay ----
118CONSTANT MaxAge
119VARIABLES screen, sent,
120 paid, clock
121Ready == sent > 0
122Tick == clock' = clock + 1 /\\ UNCHANGED <<screen, sent, paid>>
123Pay ==
124 /\\ screen = "Cart \\* not a comment"
125 /\\ MaxAge > 0
126 /\\ clock < MaxAge (* pay.ts:120 *)
127 /\\ Ready
128 \\* checkout.ts:88
129 /\\ sent < 3
130 /\\ screen' = IF clock > 0 /\\ sent > 0 THEN "Paid" ELSE screen /\\ paid' = TRUE
131 /\\ UNCHANGED <<sent, clock>>
132\\* fill.ts:10
133Fill == \\E o \\in 1..3 : /\\ paid = TRUE
134 /\\ clock > 0 /\\ sent' = o + 1
135 /\\ sent = o
136 /\\ paid => clock > 0 /\\ screen = "Cart"
137 /\\ UNCHANGED <<screen, paid, clock>>
138Next == Tick \\/ Pay \\/ Fill
139====`);
140 assert.deepEqual(found, [
141 { line: 11, action: "Pay", condition: "Ready" },
142 { line: 17, action: "Fill", condition: "paid = TRUE" },
143 { line: 18, action: "Fill", condition: "clock > 0" },
144 { line: 20, action: "Fill", condition: 'paid => clock > 0 /\\ screen = "Cart"' },
145 ]);
146});
147
148test("the legend is not part of the machine", () => {
149 const { regions } = parseMermaid(`stateDiagram-v2
150 state "Legend" as legend {
151 state "Works today" as legendWorks
152 }
153 [*] --> A
154 A --> B : go`);
155 assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]);
156});
157
158test("the parser refuses charts it cannot read", () => {
159 assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/);
160 assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/);
161 assert.throws(
162 () => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"),
163 /no \[\*\] --> start/,
164 );
165 assert.throws(
166 () =>
167 parseMermaid(
168 "stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A",
169 ),
170 /below the top level/,
171 );
172 assert.throws(
173 () => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"),
174 /final state/,
175 );
176 assert.throws(
177 () =>
178 parseMermaid(
179 'stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go',
180 ),
181 /two states have the caption "Something went wrong"/,
182 );
183});
184
185test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => {
186 const { regions } = parseMermaid(`stateDiagram-v2
187 classDef wrong fill:#fdd
188 N : Nothing chosen
189 N : press Upload to start
190 [*] --> N
191 N:::wrong --> Done : you finish
192 Done --> Moving:::wrong
193 state A {
194 [*] --> X
195 X --> Y : go
196 }
197 Moving --> A : start
198 Y --> Z : next
199 A --> N : cancel`);
200 assert.deepEqual([...regions.Main.states].sort(), [
201 "A.X",
202 "A.Y",
203 "Done",
204 "Moving",
205 "Nothing chosen",
206 "Z",
207 ]);
208 assert.deepEqual(edges(regions.Main), [
209 "A.X -> A.Y : go",
210 "A.X -> Nothing chosen : cancel",
211 "A.Y -> Nothing chosen : cancel",
212 "A.Y -> Z : next",
213 "Done -> Moving : ",
214 "Moving -> A.X : start",
215 "Nothing chosen -> Done : you finish",
216 ]);
217});
218
219test("TLC values, states, graphs, and traces parse", () => {
220 assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] });
221 assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 });
222 const graph = parseDot(`strict digraph DiskGraph {
2231 [label="/\\\\ x = 0",style = filled]
2242 [label="/\\\\ x = 1"]
2251 -> 2 [label="Inc",color="black",fontcolor="black"];
2261 -> 2 [label="Inc",color="black",fontcolor="black"];
2272 -> 2 [label="Stay"];
228}`);
229 assert.equal(graph.nodes.get("1").init, true);
230 assert.deepEqual(graph.nodes.get("2").vars, { x: 1 });
231 assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]);
232 assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]);
233 assert.throws(
234 () =>
235 parseDot(
236 'strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}',
237 ),
238 /no action names/,
239 );
240 const [trace] = parseTraces(`Error: Invariant Small is violated.
241Error: The behavior up to this point is:
242State 1: <Initial predicate>
243x = 0
244
245State 2: <Inc line 9, col 8 to line 9, col 20 of module M>
246x = 1
247`);
248 assert.equal(trace.invariant, "Small");
249 assert.deepEqual(trace.steps, [
250 { action: null, vars: { x: 0 } },
251 { action: "Inc", vars: { x: 1 } },
252 ]);
253});
254
255const done = "Model checking completed. No error has been found.\n";
256const evaluationError = `Error: Invariant NoLost is violated.
257Error: The behavior up to this point is:
258State 1: <Initial predicate>
259s = "Idle"
260
261State 2: <Lose line 4, col 9 to line 4, col 33 of module F>
262s = "Lost"
263
264Error: TLC threw an unexpected exception.
265This was probably caused by an error in the spec or model.
266
267Error: The behavior up to this point is:
268State 1: <Initial predicate>
269s = "Idle"
270
271State 2: <Save line 5, col 9 to line 5, col 35 of module F>
272s = "Saving"
273
2743 states generated, 3 distinct states found, 0 states left on queue.
275`;
276
277test("a rule broken by a start state is a counterexample of one state", () => {
278 const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state:
279/\\ badge = "Synced"
280/\\ conn = "Offline"
281
282${done}`;
283 assert.doesNotThrow(() => tlcOutputWarning(text, "tlc.out"));
284 assert.deepEqual(parseTraces(text), [
285 {
286 invariant: "NeverSyncedWhileOffline",
287 steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }],
288 },
289 ]);
290});
291
292test("TLC output from a run that failed or stopped early is refused", () => {
293 assert.doesNotThrow(() =>
294 tlcOutputWarning(
295 `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: <Initial predicate>\nx = 0\n\n${done}`,
296 "ok.out",
297 ),
298 );
299 assert.throws(
300 () =>
301 tlcOutputWarning(
302 "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n",
303 "cfg.out",
304 ),
305 /cfg\.out: TLC stopped on an error that is not a broken rule/,
306 );
307 assert.throws(
308 () => tlcOutputWarning("Error: Parsing or semantic analysis failed.\n", "parse.out"),
309 /not a broken rule/,
310 );
311 assert.throws(
312 () => tlcOutputWarning(evaluationError, "eval.out"),
313 /TLC threw an unexpected exception/,
314 );
315 assert.throws(
316 () =>
317 tlcOutputWarning(
318 "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: <Initial predicate>\nx = 0\n",
319 "stopped.out",
320 ),
321 /did not finish/,
322 );
323 assert.deepEqual(
324 parseTraces(evaluationError).map((t) => t.invariant),
325 ["NoLost", null],
326 );
327});
328
329const tlcThrew = (cls, message) => `Error: Evaluating invariant Small failed.
330Index 123 out of bounds for length 4
331Error: TLC threw an unexpected exception.
332This was probably caused by an error in the spec or model.
333See the User Output or TLC Console for clues to what happened.
334The exception was a ${cls}
335: ${message}
336Error: The error occurred when TLC was evaluating the nested
337expressions at the following positions:
338 The error call stack is empty.
339`;
340const smallBroken =
341 "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: <Initial predicate>\nx = 0\n\n";
342const stoppedEarly = "9 states generated, 9 distinct states found, 4 states left on queue.\n";
343
344test("a rule that TLC finds broken before TLC throws a Java exception keeps its paths, with a warning", () => {
345 const aioobe = "java.lang.ArrayIndexOutOfBoundsException: Index 123 out of bounds for length 4";
346 const oneWorker = `${smallBroken}Error: Invariant Small is violated.\n${tlcThrew(
347 "java.lang.ArrayIndexOutOfBoundsException",
348 "Index 123 out of bounds for length 4",
349 )}\n${stoppedEarly}`;
350 assert.equal(
351 tlcOutputWarning(oneWorker, "tlc.out"),
352 `TLC found that Small is broken, and then failed with ${aioobe}. TLC stopped before it checked every state, so the charts can miss states that break Small. The failure is in TLC, not in the rule. Do not weaken Small because of it. A smaller constant can let TLC finish.`,
353 );
354 const workersInterleaved = `${tlcThrew("java.lang.RuntimeException", aioobe)}${smallBroken}${stoppedEarly}`;
355 assert.match(
356 tlcOutputWarning(workersInterleaved, "tlc.out"),
357 /failed with java\.lang\.ArrayIndexOutOfBoundsException: Index 123/,
358 );
359 assert.deepEqual(
360 parseTraces(workersInterleaved).map((t) => t.invariant),
361 ["Small"],
362 );
363 assert.throws(
364 () =>
365 tlcOutputWarning(
366 `${smallBroken}${tlcThrew("java.lang.RuntimeException", "util.Assert$TLCRuntimeException: Attempted to apply tuple")}${stoppedEarly}`,
367 "formula.out",
368 ),
369 /formula\.out: TLC stopped on an error that is not a broken rule: Evaluating invariant Small failed\./,
370 );
371 assert.throws(
372 () =>
373 tlcOutputWarning(
374 `${tlcThrew("java.lang.ArrayIndexOutOfBoundsException", "Index 123 out of bounds for length 4")}${stoppedEarly}`,
375 "never-broken.out",
376 ),
377 /not a broken rule: Evaluating invariant Small failed\./,
378 );
379});
380
381test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => {
382 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
383 writeFileSync(
384 join(dir, "save.dot"),
385 `strict digraph DiskGraph {
3861 [label="/\\\\ s = \\"Idle\\"",style = filled]
3872 [label="/\\\\ s = \\"Saving\\""]
3881 -> 2 [label="Tap"];
3892 -> 2 [label="Retry"];
390}`,
391 );
392 writeFileSync(
393 join(dir, "save.map.mjs"),
394 `export const readerActions = ["Tap"];
395export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving");
396export const step = (action) => ({ Tap: "you tap Save" })[action] ?? "";
397`,
398 );
399 const r = node(
400 "tlc-to-chart.mjs",
401 ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"],
402 dir,
403 );
404 assert.equal(r.status, 0, r.stderr);
405 assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), [
406 'Badge says "Synced" -> Saving : you tap Save',
407 ]);
408});
409
410test("chart-to-tla writes one action per label and one invariant per never line", () => {
411 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
412 writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd"));
413 const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir);
414 assert.equal(r.status, 0, r.stderr);
415 const spec = readFileSync(join(dir, "Sync.tla"), "utf8");
416 assert.match(spec, /^VARIABLES connection, syncBadge$/m);
417 assert.match(
418 spec,
419 /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m,
420 );
421 assert.match(
422 spec,
423 /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m,
424 );
425 assert.equal(
426 readFileSync(join(dir, "Sync.cfg"), "utf8"),
427 "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n",
428 );
429 assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/);
430});
431
432test("chart-to-tla refuses a never line that names no state", () => {
433 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
434 writeFileSync(
435 join(dir, "c.mmd"),
436 "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n",
437 );
438 const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir);
439 assert.notEqual(r.status, 0);
440 assert.match(r.stderr, /names "C", which is not a state/);
441});
442
443test("a never line names a state exactly, or every state inside a composite", () => {
444 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
445 writeFileSync(
446 join(dir, "c.mmd"),
447 `stateDiagram-v2
448 state "Saving" as S
449 state "Saving..." as S2
450 state Busy {
451 [*] --> Checking
452 Checking --> Sending : the check passes
453 }
454 [*] --> S
455 S --> S2 : you wait
456 S2 --> Busy : you send
457 Busy --> S : you cancel
458 %% never NoSaving: Saving -- You never see Saving.
459 %% never NoBusy: Busy -- You are never busy.
460`,
461 );
462 const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir);
463 assert.equal(r.status, 0, r.stderr);
464 const spec = readFileSync(join(dir, "C.tla"), "utf8");
465 assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m);
466 assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m);
467 assert.match(
468 readFileSync(join(dir, "C.map.mjs"), "utf8"),
469 /^export const readerActions = \[\];$/m,
470 );
471});
472
473const door = `stateDiagram-v2
474 state Door {
475 [*] --> Locked
476 Locked --> Unlocked : tap unlock
477 Unlocked --> Locked : tap lock
478 --
479 [*] --> Closed
480 Closed --> Open : open the door
481 Open --> Closed : close the door
482 }
483 [*] --> Door`;
484const flatDoor = (extra = []) =>
485 [
486 "stateDiagram-v2",
487 ' state "Locked | Closed" as L_C',
488 ' state "Locked | Open" as L_O',
489 ' state "Unlocked | Closed" as U_C',
490 ' state "Unlocked | Open" as U_O',
491 " [*] --> L_C",
492 " L_C --> U_C : tap unlock",
493 " U_C --> L_C : tap lock",
494 " L_O --> U_O : tap unlock",
495 " L_C --> L_O : open the door",
496 " L_O --> L_C : close the door",
497 " U_C --> U_O : open the door",
498 " U_O --> U_C : close the door",
499 ...extra,
500 ].join("\n");
501
502test("compare reads parallel regions as one machine, so a guard across regions shows", () => {
503 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
504 writeFileSync(join(dir, "agreed.mmd"), door);
505 writeFileSync(join(dir, "guarded.mmd"), flatDoor());
506 writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"]));
507 const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir);
508 assert.equal(differ.status, 1);
509 assert.match(
510 differ.stdout,
511 /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/,
512 );
513 const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir);
514 assert.equal(same.stdout.trim(), "same", same.stdout);
515});
516
517const lamp = `stateDiagram-v2
518 state "Lamp" as lamp {
519 state "Power" as power {
520 [*] --> Off
521 Off --> On : flip the switch
522 On --> Off : flip the switch
523 }
524 --
525 state "Bulb" as bulb {
526 [*] --> Dark
527 Dark --> Lit : warm up [when the<br/>power is on]
528 %% when On
529 Lit --> Dark : cool down [when the power is off]
530 %% when not On
531 }
532 }
533 %% never LitWhileOff: Off & Lit -- The bulb is never lit while the power is off.
534`;
535
536test("a %% when line guards a step on the other region's states, and [words] are only its text", () => {
537 const { regions } = parseMermaid(lamp);
538 assert.deepEqual(regions.Bulb.transitions, [
539 { from: "Dark", to: "Lit", label: "warm up", guard: { not: false, states: { Power: ["On"] } } },
540 {
541 from: "Lit",
542 to: "Dark",
543 label: "cool down",
544 guard: { not: true, states: { Power: ["On"] } },
545 },
546 ]);
547 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
548 writeFileSync(join(dir, "lamp.mmd"), lamp);
549 writeFileSync(
550 join(dir, "flat.mmd"),
551 [
552 "stateDiagram-v2",
553 " [*] --> OD",
554 ' state "Off | Dark" as OD',
555 ' state "On | Dark" as ND',
556 ' state "On | Lit" as NL',
557 ' state "Off | Lit" as OL',
558 " OD --> ND : flip the switch",
559 " ND --> OD : flip the switch",
560 " ND --> NL : warm up",
561 " NL --> OL : flip the switch",
562 " OL --> NL : flip the switch",
563 " OL --> OD : cool down",
564 ].join("\n"),
565 );
566 const same = node("compare-charts.mjs", ["lamp.mmd", "flat.mmd"], dir);
567 assert.equal(same.stdout, "same\n", same.stderr);
568 const tla = node("chart-to-tla.mjs", ["lamp.mmd", "--module", "Lamp"], dir);
569 assert.equal(tla.status, 0, tla.stderr);
570 const spec = readFileSync(join(dir, "Lamp.tla"), "utf8");
571 assert.match(spec, /\(bulb = "Dark" \/\\ power = "On" \/\\ bulb' = "Lit"\)/);
572 assert.match(spec, /\(bulb = "Lit" \/\\ ~\(power = "On"\) \/\\ bulb' = "Dark"\)/);
573
574 for (const [from, to, why] of [
575 [
576 " %% when On\n",
577 " state Glow\n %% when On\n",
578 /must come right after a transition/,
579 ],
580 [
581 "warm up [when the<br/>power is on]",
582 "warm up",
583 /must end its label with the guard's words in \[ \]/,
584 ],
585 [" %% when On\n", " %% when Nope\n", /names Nope, which is not a state id/],
586 [" %% when On\n", " %% when Lit\n", /names Lit, which is in the step's own region/],
587 ])
588 assert.throws(() => parseMermaid(lamp.replace(from, to)), why);
589 assert.throws(
590 () =>
591 parseMermaid(
592 "stateDiagram-v2\n [*] --> A\n A --> B : go [when C]\n %% when C\n B --> C : on\n",
593 ),
594 /the chart has none/,
595 );
596 assert.deepEqual(
597 edges(parseMermaid("stateDiagram-v2\n [*] --> A\n A --> B : go [user away]\n").regions.Main),
598 ["A -> B : go"],
599 );
600});
601
602const switchLamp = `stateDiagram-v2
603 state "Lamp" as lamp {
604 state "Power" as power {
605 [*] --> Off
606 Off --> On : flip the switch
607 On --> Off : flip the switch
608 }
609 --
610 state "Bulb" as bulb {
611 [*] --> Dark
612 Dark --> Lit : flip the switch [when the power is off]
613 %% when Off
614 Lit --> Dark : flip the switch
615 }
616 }
617`;
618
619test("a label with a guarded and an unguarded move in one region writes both moves", () => {
620 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
621 writeFileSync(join(dir, "switch.mmd"), switchLamp);
622 const r = node("chart-to-tla.mjs", ["switch.mmd", "--module", "Switch"], dir);
623 assert.equal(r.status, 0, r.stderr);
624 const spec = readFileSync(join(dir, "Switch.tla"), "utf8");
625 assert.equal(
626 spec.slice(spec.indexOf("\\* flip the switch"), spec.indexOf("\nNext ==")),
627 `\\* flip the switch
628FlipTheSwitch ==
629 /\\ (power \\in {"Off", "On"} \\/ (bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit"))
630 /\\ \\/ (power = "Off" /\\ power' = "On")
631 \\/ (power = "On" /\\ power' = "Off")
632 \\/ (power \\notin {"Off", "On"} /\\ UNCHANGED power)
633 /\\ \\/ (bulb = "Dark" /\\ power = "Off" /\\ bulb' = "Lit")
634 \\/ (bulb = "Lit" /\\ bulb' = "Dark")
635 \\/ (~((bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) /\\ UNCHANGED bulb)
636`,
637 );
638});
639
640test("compare reads a one-region chart as the machine its steps reach, without steps that change nothing", () => {
641 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
642 const drawn =
643 "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy : start\n Busy --> Idle : finish\n";
644 writeFileSync(join(dir, "drawn.mmd"), drawn);
645 writeFileSync(join(dir, "loop.mmd"), `${drawn} Idle --> Idle : refresh\n`);
646 writeFileSync(join(dir, "orphan.mmd"), `${drawn} Lost --> Idle : come back\n`);
647 const same = node("compare-charts.mjs", ["loop.mmd", "drawn.mmd"], dir);
648 assert.equal(same.status, 0);
649 assert.equal(same.stdout, "same\n");
650 const orphan = node("compare-charts.mjs", ["orphan.mmd", "drawn.mmd"], dir);
651 assert.equal(orphan.status, 1);
652 assert.equal(
653 orphan.stdout,
654 "states the agreed chart's own steps never reach:\n Lost\n1 difference(s)\n",
655 );
656});
657
658test("a -- outside a composite is refused, not read as one region", () => {
659 const split =
660 "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n";
661 assert.throws(() => parseMermaid(split), /-- outside a composite/);
662 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
663 writeFileSync(join(dir, "split.mmd"), split);
664 writeFileSync(join(dir, "door.mmd"), door);
665 const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir);
666 assert.notEqual(tla.status, 0);
667 assert.match(tla.stderr, /-- outside a composite/);
668 for (const pair of [
669 ["split.mmd", "split.mmd"],
670 ["door.mmd", "split.mmd"],
671 ["split.mmd", "door.mmd"],
672 ]) {
673 const r = node("compare-charts.mjs", pair, dir);
674 assert.equal(r.status, 2, pair.join(" "));
675 assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/);
676 }
677});
678
679test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => {
680 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
681 writeFileSync(join(dir, "agreed.mmd"), door);
682 for (const pair of [
683 ["agreed.mmd", "missing.mmd"],
684 ["missing.mmd", "agreed.mmd"],
685 ]) {
686 const r = node("compare-charts.mjs", pair, dir);
687 assert.equal(r.status, 2, pair.join(" "));
688 assert.match(r.stderr, /^missing\.mmd: ENOENT/);
689 }
690 for (const [extra, why] of [
691 [" U_O --> L_O : 1. tap lock", /--highlight 0/],
692 [
693 " %% second model (against): the spec",
694 "actual.mmd: it has a second model; draw ACTUAL without --fixed-dot or --against-dot\n",
695 ],
696 [
697 ' state "Locked" as g1 {\n state "Closed" as L_C2\n }',
698 "actual.mmd: it has boxes; draw ACTUAL with --flat\n",
699 ],
700 ]) {
701 writeFileSync(join(dir, "actual.mmd"), flatDoor([extra]));
702 const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir);
703 assert.equal(r.status, 2, extra);
704 if (typeof why === "string") assert.equal(r.stderr, why);
705 else assert.match(r.stderr, why);
706 }
707});
708
709test("an edge lists every step, writes shared leading words once, and the chart renders at its natural width", () => {
710 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
711 const nodes = [
712 ["Home", 1],
713 ["Home", 2],
714 ["Home", 3],
715 ["Away", 1],
716 ["Away", 2],
717 ["Done", 1],
718 ].map(
719 ([s, n], i) =>
720 `${i + 1} [label="/\\\\ s = \\"${s}\\"\\n/\\\\ n = ${n}"${i ? "" : ",style = filled"}]`,
721 );
722 const steps = [
723 "1 -> 4 TapPay",
724 "2 -> 5 TapBack",
725 "3 -> 4 TapHome",
726 "4 -> 1 OpenThread",
727 "5 -> 2 OpenApp",
728 "4 -> 6 Finish",
729 ];
730 writeFileSync(
731 join(dir, "nav.dot"),
732 `strict digraph DiskGraph {\n${nodes.join("\n")}\n${steps.map((e) => e.replace(/ (\w+)$/, ' [label="$1"];')).join("\n")}\n}\n`,
733 );
734 writeFileSync(
735 join(dir, "nav.map.mjs"),
736 `export const state = (v) => v.s;
737export 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];
738`,
739 );
740 writeFileSync(
741 join(dir, "agreed.mmd"),
742 `stateDiagram-v2
743 [*] --> Home
744 Home --> Away : you tap Pay
745 Home --> Away : you tap Back
746 Home --> Away : you tap Home
747 Away --> Home : you open the thread
748 Away --> Home : you open the phone app
749 Away --> Done
750`,
751 );
752 const draw = (...extra) => {
753 const r = node(
754 "tlc-to-chart.mjs",
755 [
756 "--dot",
757 "nav.dot",
758 "--map",
759 "nav.map.mjs",
760 "--highlight",
761 "0",
762 "--out",
763 "chart.mmd",
764 ...extra,
765 ],
766 dir,
767 );
768 assert.equal(r.status, 0, r.stderr);
769 return readFileSync(join(dir, "chart.mmd"), "utf8");
770 };
771 const chart = draw();
772 assert.ok(
773 chart.startsWith(
774 "---\nconfig:\n markdownAutoWrap: false\n state:\n useMaxWidth: false\n---\nstateDiagram-v2\n",
775 ),
776 chart,
777 );
778 const shortened = [
779 "Away -> Done : ",
780 "Away -> Home : you open the thread or the phone app",
781 "Home -> Away : you tap Pay, Back, or Home",
782 ];
783 assert.deepEqual(edges(parseMermaid(chart).regions.Main), shortened);
784 const crlf = chart.replace("---\n", "--- \n").replaceAll("\n", "\r\n");
785 assert.deepEqual(edges(parseMermaid(crlf).regions.Main), shortened);
786 const refused = node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir);
787 assert.equal(refused.status, 2);
788 assert.match(refused.stderr, /draw ACTUAL with --full-labels/);
789 const full = draw("--full-labels");
790 assert.match(full, /^stateDiagram-v2\n {2}%% labels in full\n/m);
791 assert.deepEqual(edges(parseMermaid(full).regions.Main), [
792 "Away -> Done : ",
793 "Away -> Home : you open the thread or you open the phone app",
794 "Home -> Away : you tap Pay or you tap Back or you tap Home",
795 ]);
796 assert.equal(node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir).stdout, "same\n");
797});
798
799const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release:
800 https://github.com/tlaplus/tlaplus/releases
801Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).
802`;
803const FROM_SPEC_USAGE =
804 "node statechart.mjs from-spec SPEC.tla [--config FILE] [--fixed FIXED.tla [--fixed-config FILE] | --against AGREED.tla [--against-config FILE]] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]";
805const FROM_CHART_USAGE =
806 "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]";
807const noTlc = { TLA2TOOLS: "", JAVA: "" };
808
809test("statechart.mjs writes the spec next to the chart, keeps it on the next run, and stops without TLC", () => {
810 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
811 writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd"));
812 const first = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc);
813 assert.equal(first.status, 2);
814 assert.equal(
815 first.stdout,
816 "Wrote NotesApp.tla, NotesApp.cfg, and NotesApp.map.mjs from notes-app.mmd.\n",
817 );
818 assert.equal(first.stderr, NO_TLC);
819 assert.equal(
820 readFileSync(join(dir, "NotesApp.cfg"), "utf8"),
821 "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n",
822 );
823 const again = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc);
824 assert.equal(
825 again.stdout,
826 "Kept NotesApp.tla. Delete it to write the spec again from notes-app.mmd.\n",
827 );
828 const spec = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, noTlc);
829 assert.equal(spec.status, 2);
830 assert.equal(spec.stderr, NO_TLC);
831 const missingJar = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, {
832 TLA2TOOLS: "/nonexistent/tla2tools.jar",
833 });
834 assert.equal(missingJar.status, 2);
835 assert.equal(
836 missingJar.stderr,
837 "TLA2TOOLS is /nonexistent/tla2tools.jar, which does not exist.\n",
838 );
839});
840
841test("statechart.mjs writes no output folder when it refuses the input", () => {
842 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
843 writeFileSync(join(dir, "tla2tools.jar"), "");
844 writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n");
845 writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT Paid\n");
846 writeFileSync(join(dir, "cart.mmd"), "stateDiagram-v2\n [*] --> Cart\n Cart --> Paid : pay\n");
847 for (const args of [
848 ["from-spec", "Pya.tla"],
849 ["from-spec", "Pay.tla", "--expect-broken", "Unpaid"],
850 ["from-spec", "Pay.tla", "--fixed", "PayFixd.tla"],
851 ["from-chart", "cart.mmd", "--expect-broken", "Unpaid"],
852 ]) {
853 const r = node("statechart.mjs", args, dir, { TLA2TOOLS: join(dir, "tla2tools.jar") });
854 assert.equal(r.status, 2, r.stderr);
855 }
856 assert.deepEqual(
857 readdirSync(dir).filter((f) => f.endsWith("-statechart")),
858 [],
859 );
860});
861
862test("statechart.mjs does not use an output folder that has files that it did not write", () => {
863 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
864 writeFileSync(join(dir, "tla2tools.jar"), "");
865 writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n");
866 writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n");
867 const env = { TLA2TOOLS: join(dir, "tla2tools.jar") };
868 const cwd = node("statechart.mjs", ["from-spec", "Pay.tla", "--out", "."], dir, env);
869 assert.equal(cwd.status, 2);
870 assert.equal(
871 cwd.stderr,
872 ". 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",
873 );
874 const out = join(dir, "Pay-statechart");
875 mkdirSync(join(out, "tlc"), { recursive: true });
876 writeFileSync(join(out, "chart.mmd"), "kept\n");
877 writeFileSync(join(out, ".DS_Store"), "");
878 writeFileSync(join(out, "notes.md"), "mine\n");
879 const notes = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, env);
880 assert.equal(notes.status, 2);
881 assert.equal(
882 notes.stderr,
883 "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",
884 );
885 assert.deepEqual(readdirSync(dir).sort(), [
886 "Pay-statechart",
887 "Pay.cfg",
888 "Pay.tla",
889 "tla2tools.jar",
890 ]);
891 assert.deepEqual(readdirSync(out).sort(), [".DS_Store", "chart.mmd", "notes.md", "tlc"]);
892 assert.equal(readFileSync(join(out, "chart.mmd"), "utf8"), "kept\n");
893});
894
895test("statechart.mjs exits 2 and names a helper script that cannot start", () => {
896 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
897 writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd"));
898 writeFileSync(join(dir, "no-node.cjs"), 'process.execPath = "/nonexistent/node";\n');
899 const r = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, {
900 NODE_OPTIONS: `--require ${join(dir, "no-node.cjs")}`,
901 });
902 assert.equal(r.status, 2, r.stderr);
903 assert.match(r.stderr, /chart-to-tla\.mjs/);
904});
905
906test("statechart.mjs names the chart it refuses and the argument it does not know", () => {
907 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
908 writeFileSync(
909 join(dir, "c.mmd"),
910 "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n",
911 );
912 const refused = node("statechart.mjs", ["from-chart", "c.mmd"], dir);
913 assert.equal(refused.status, 2);
914 assert.equal(refused.stdout, "");
915 assert.equal(refused.stderr, 'c.mmd: never line names "C", which is not a state in the chart\n');
916 const unknown = node("statechart.mjs", ["from-chart", "c.mmd", "--fixed", "CFixed.tla"], dir);
917 assert.equal(unknown.status, 2);
918 assert.equal(
919 unknown.stderr,
920 "unknown argument --fixed. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n",
921 );
922 const sameName = node("statechart.mjs", ["from-spec", "C.tla", "--fixed", "fix/C.tla"], dir);
923 assert.equal(sameName.status, 2);
924 assert.equal(
925 sameName.stderr,
926 "fix/C.tla needs its own file and module name, such as CFixed.tla.\n",
927 );
928 const sameAgreed = node("statechart.mjs", ["from-spec", "C.tla", "--against", "a/C.tla"], dir);
929 assert.equal(
930 sameAgreed.stderr,
931 "a/C.tla needs its own file and module name, such as CAgreed.tla.\n",
932 );
933 for (const [extra, stderr] of [
934 [
935 ["--fixed", "F.tla", "--against", "A.tla"],
936 "use --fixed or --against, not both. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n",
937 ],
938 [
939 ["--label", "the new build"],
940 "--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",
941 ],
942 ]) {
943 const r = node("statechart.mjs", ["from-spec", "C.tla", ...extra], dir);
944 assert.equal(r.status, 2, extra.join(" "));
945 assert.equal(r.stderr, stderr);
946 }
947 const valueless = node("statechart.mjs", ["from-chart", "c.mmd", "--out"], dir);
948 assert.equal(valueless.status, 2);
949 assert.equal(
950 valueless.stderr,
951 "--out needs a value. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n",
952 );
953 const bare = node("statechart.mjs", [], dir);
954 assert.equal(bare.status, 2);
955 assert.equal(
956 bare.stderr,
957 `Usage: ${FROM_SPEC_USAGE}
958 ${FROM_CHART_USAGE}
959Run node statechart.mjs --help for the flags, the output, and the exit codes.
960`,
961 );
962});
963
964test("every script prints its help with -h or --help, and points at it for an argument it does not know", () => {
965 const help = {
966 "statechart.mjs": [[], `Usage: ${FROM_SPEC_USAGE}`],
967 "statechart.mjs from-spec": [["from-spec"], `Usage: ${FROM_SPEC_USAGE}`],
968 "statechart.mjs from-chart": [["from-chart", "notes-app.mmd"], `Usage: ${FROM_CHART_USAGE}`],
969 "chart-to-tla.mjs": [[], "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"],
970 "tlc-to-chart.mjs": [[], "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"],
971 "compare-charts.mjs": [[], "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"],
972 };
973 for (const [name, [before, usage]] of Object.entries(help)) {
974 const script = name.split(" ")[0];
975 for (const flag of ["-h", "--help"]) {
976 const r = node(script, [...before, flag], here);
977 assert.equal(r.status, 0, `${name} ${flag}`);
978 assert.equal(r.stderr, "");
979 assert.equal(r.stdout.split("\n")[0], usage);
980 assert.match(r.stdout, /\nExit codes\n {2}0 .*\n(.*\n)* {2}1 .*\n(.*\n)* {2}2 /);
981 }
982 }
983 const top = node("statechart.mjs", ["--help"], here).stdout.split("\n");
984 assert.equal(top[1], ` ${FROM_CHART_USAGE}`);
985 assert.equal(top[2], " node statechart.mjs [from-spec|from-chart] --help");
986 for (const flag of [
987 "--fixed FIXED.tla",
988 "--label TEXT",
989 "--map FILE",
990 "--module NAME",
991 "--title TEXT",
992 "--out DIR",
993 "--png",
994 "-h, --help",
995 ])
996 assert.ok(
997 top.some((l) => l.startsWith(` ${flag} `)),
998 flag,
999 );
1000 for (const [script, args, stderr] of [
1001 [
1002 "statechart.mjs",
1003 ["from-spec", "--nope"],
1004 "unknown argument --nope. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n",
1005 ],
1006 [
1007 "chart-to-tla.mjs",
1008 ["--nope"],
1009 "unknown argument --nope. Run node chart-to-tla.mjs --help for the flags.\n",
1010 ],
1011 [
1012 "tlc-to-chart.mjs",
1013 ["--nope"],
1014 "unknown argument --nope. Run node tlc-to-chart.mjs --help for the flags.\n",
1015 ],
1016 [
1017 "tlc-to-chart.mjs",
1018 ["--dot", "a.dot", "--map", "a.map.mjs", "--regions"],
1019 "--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",
1020 ],
1021 [
1022 "tlc-to-chart.mjs",
1023 ["--dot", "a.dot", "--map", "a.map.mjs", "--proof", "p.mmd"],
1024 "--proof writes the regions proof; it needs --regions. Run node tlc-to-chart.mjs --help for the flags.\n",
1025 ],
1026 [
1027 "compare-charts.mjs",
1028 ["--nope"],
1029 "unknown argument --nope. Run node compare-charts.mjs --help for the flags.\n",
1030 ],
1031 ]) {
1032 const r = node(script, args, here);
1033 assert.equal(r.status, 2, script);
1034 assert.equal(r.stdout, "");
1035 assert.equal(r.stderr, stderr);
1036 }
1037});
1038
1039const tools = (() => {
1040 try {
1041 return findTlc();
1042 } catch {
1043 return null;
1044 }
1045})();
1046const needsTlc = { skip: tools ? false : "cannot find TLC" };
1047
1048function tlc(dir, name) {
1049 const r = spawnSync(
1050 tools.java,
1051 [
1052 "-cp",
1053 tools.jar,
1054 "tlc2.TLC",
1055 "-metadir",
1056 mkdtempSync(join(tmpdir(), "tlc-")),
1057 "-deadlock",
1058 "-continue",
1059 "-dump",
1060 "dot,actionlabels",
1061 `${name}.dot`,
1062 "-config",
1063 `${name}.cfg`,
1064 `${name}.tla`,
1065 ],
1066 { cwd: dir, encoding: "utf8" },
1067 );
1068 writeFileSync(join(dir, `${name}.out`), r.stdout);
1069 return r;
1070}
1071
1072test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, () => {
1073 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1074 writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd"));
1075 const r = node("statechart.mjs", ["from-chart", "upload.mmd"], dir);
1076 assert.equal(r.status, 0, r.stderr);
1077 assert.equal(
1078 r.stdout,
1079 `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd.
1080TLC on Upload.tla with Upload.cfg:
1081 TypeOK holds.
1082 TLC ran 2 times in N s.
1083
1084Chart: 5 states and 8 transitions from 5 model states.
10855 work, 0 break a rule.
1086
1087No state breaks a rule.
1088
1089The spec draws upload.mmd back unchanged.
1090
1091Wrote chart.mmd, stories.md, summary.json in Upload-statechart.
1092`,
1093 );
1094 assert.equal(r.stderr, "");
1095});
1096
1097test("TLC reads a label with a guarded and an unguarded move in one region", needsTlc, () => {
1098 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1099 writeFileSync(join(dir, "switch.mmd"), switchLamp);
1100 const r = node("statechart.mjs", ["from-chart", "switch.mmd"], dir);
1101 assert.equal(r.status, 0, r.stdout + r.stderr);
1102 assert.match(r.stdout, /\nThe spec draws switch\.mmd back unchanged\.\n/);
1103});
1104
1105const syncStory = `## 1. Offline | Synced
1106
1107Rule broken: You never see "Synced" while you are offline.
1108This is the only way to get here in the model.
1109
11100. Start: Online | Synced
11111. The network drops. Now: Offline | Waiting to sync
11122. The server confirms. Now: Offline | Synced
1113`;
1114
1115test("from-chart finds the design bug, and from-spec with the fix marks it fixed", needsTlc, () => {
1116 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1117 writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd"));
1118 const today = node("statechart.mjs", ["from-chart", "sync.mmd"], dir);
1119 assert.equal(today.status, 1, today.stderr);
1120 assert.equal(
1121 today.stdout,
1122 `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd.
1123TLC on Sync.tla with Sync.cfg:
1124 TypeOK holds.
1125 NeverSyncedWhileOffline is broken.
1126 TLC ran 4 times in N s.
1127
1128Chart: 4 states and 8 transitions from 4 model states.
11293 work, 1 breaks a rule.
1130
1131${syncStory}
1132The spec draws sync.mmd back unchanged.
1133
1134Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart.
1135`,
1136 );
1137 assert.match(
1138 readFileSync(join(dir, "Sync-statechart", "bug-1.mmd"), "utf8"),
1139 /: 2\. the server confirms\n/,
1140 );
1141
1142 const spec = readFileSync(join(dir, "Sync.tla"), "utf8");
1143 const guarded = spec
1144 .replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n')
1145 .replace("MODULE Sync", "MODULE SyncFixed");
1146 assert.notEqual(guarded, spec);
1147 writeFileSync(join(dir, "SyncFixed.tla"), guarded);
1148 const fixed = node("statechart.mjs", ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], dir);
1149 assert.equal(fixed.status, 1, fixed.stderr);
1150 assert.equal(
1151 fixed.stdout,
1152 `TLC on Sync.tla with Sync.cfg:
1153 TypeOK holds.
1154 NeverSyncedWhileOffline is broken.
1155 TLC ran 4 times in N s.
1156TLC on SyncFixed.tla with Sync.cfg:
1157 TypeOK holds.
1158 NeverSyncedWhileOffline holds.
1159 TLC ran 3 times in N s.
1160
1161Chart: 4 states and 9 transitions from 4 model states.
11623 work today, 0 still wrong with the fix, 1 fixed or new with the fix.
1163
1164${syncStory}
1165With the fix, step 2 (the server confirms) cannot happen, so you stay at: Offline | Waiting to sync.
1166
1167Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart.
1168`,
1169 );
1170 const summary = JSON.parse(readFileSync(join(dir, "Sync-statechart", "summary.json"), "utf8"));
1171 assert.deepEqual(summary.chartStatus, { works: 3, wrong: 0, fixed: 1 });
1172 assert.equal(summary.classes[0].fix, "Offline | Waiting to sync");
1173
1174 const map = readFileSync(join(dir, "Sync.map.mjs"), "utf8");
1175 writeFileSync(join(dir, "Sync.map.mjs"), `${map}export const group = () => "Your notes";\n`);
1176 const grouped = node(
1177 "statechart.mjs",
1178 ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"],
1179 dir,
1180 );
1181 assert.equal(grouped.status, 1, grouped.stderr);
1182 const lines = readFileSync(join(dir, "Sync-statechart", "chart.mmd"), "utf8").split("\n");
1183 assert.deepEqual(
1184 lines.filter((l) => /^ {2}(classDef|class) |legend/.test(l)),
1185 [
1186 " classDef plainFixed fill:#f4f6f7,stroke:#1e8449,stroke-width:4px,color:#145a32",
1187 " classDef group0 fill:#d6eaf8,stroke:#6b757c,color:#1b2631",
1188 " classDef group0Fixed fill:#d6eaf8,stroke:#1e8449,stroke-width:4px,color:#145a32",
1189 ' state "Legend" as legend {',
1190 ' state "Your notes" as legendGroup0',
1191 ' state "Fixed or new with the fix" as legendFixed',
1192 " class legendGroup0 group0",
1193 " class legendFixed plainFixed",
1194 " class s0 group0Fixed",
1195 " class s1 group0",
1196 " class s2 group0",
1197 " class s3 group0",
1198 ],
1199 );
1200 writeFileSync(join(dir, "Sync.map.mjs"), map);
1201
1202 writeFileSync(join(dir, "Sync.tla"), guarded.replace("MODULE SyncFixed", "MODULE Sync"));
1203 const coded = node("statechart.mjs", ["from-chart", "sync.mmd"], dir);
1204 assert.equal(coded.status, 1, coded.stderr);
1205 assert.equal(
1206 coded.stdout,
1207 `Kept Sync.tla. Delete it to write the spec again from sync.mmd.
1208TLC on Sync.tla with Sync.cfg:
1209 TypeOK holds.
1210 NeverSyncedWhileOffline holds.
1211 TLC ran 3 times in N s.
1212
1213Chart: 3 states and 5 transitions from 3 model states.
12143 work, 0 break a rule.
1215
1216No state breaks a rule.
1217
1218The spec does not draw sync.mmd:
1219 states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them):
1220 Offline | Synced
1221 transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step):
1222 Offline | Synced --> Offline | Waiting to sync : the network drops
1223 Offline | Synced --> Offline | Waiting to sync : you edit a note
1224 Offline | Synced --> Online | Synced : the network returns
1225 Offline | Waiting to sync --> Offline | Synced : the server confirms
1226 5 difference(s)
1227
1228Wrote chart.mmd, stories.md, summary.json in Sync-statechart.
1229`,
1230 );
1231 assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [
1232 "chart.mmd",
1233 "stories.md",
1234 "summary.json",
1235 "tlc",
1236 ]);
1237});
1238
1239test(
1240 "--png exits 2 unless every chart renders, even with --expect-broken, and keeps the files it wrote",
1241 needsTlc,
1242 () => {
1243 const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline", "--png"];
1244 const bin = mkdtempSync(join(tmpdir(), "fake-npx-"));
1245 const launch = "Error: Failed to launch the browser process!";
1246 writeFileSync(
1247 join(bin, "npx"),
1248 `#!/bin/sh
1249while [ $# -gt 0 ]; do
1250 case "$1" in -i) chart=$2 ;; -o) png=$2 ;; esac
1251 shift
1252done
1253for c in $UNRENDERED; do
1254 case "$chart" in */$c.mmd) echo "${launch}" >&2; exit 1 ;; esac
1255done
1256echo png > "$png"
1257`,
1258 { mode: 0o755 },
1259 );
1260 for (const [unrendered, status, written] of [
1261 ["chart bug-1", 2, ["chart.mmd", "bug-1.mmd"]],
1262 ["bug-1", 2, ["chart.mmd", "bug-1.mmd", "chart.png"]],
1263 ["", 0, ["chart.mmd", "bug-1.mmd", "chart.png", "bug-1.png"]],
1264 ]) {
1265 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1266 writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd"));
1267 const r = node("statechart.mjs", args, dir, {
1268 PATH: `${bin}${delimiter}${process.env.PATH}`,
1269 UNRENDERED: unrendered,
1270 });
1271 assert.equal(r.status, status, `${unrendered}: ${r.stdout}${r.stderr}`);
1272 assert.equal(
1273 r.stderr,
1274 unrendered
1275 .split(" ")
1276 .filter(Boolean)
1277 .map((c) => `could not render Sync-statechart/${c}.mmd: ${launch}\n`)
1278 .join(""),
1279 );
1280 assert.match(r.stdout, /\nBroken as expected: NeverSyncedWhileOffline\.\n/);
1281 assert.equal(
1282 r.stdout.split("\n").at(-2),
1283 `Wrote ${[...written, "stories.md", "summary.json"].join(", ")} in Sync-statechart.`,
1284 );
1285 assert.deepEqual(
1286 readdirSync(join(dir, "Sync-statechart")).sort(),
1287 [...written, "stories.md", "summary.json", "tlc"].sort(),
1288 );
1289 }
1290 },
1291);
1292
1293const pay = (name, charge) => `---- MODULE ${name} ----
1294EXTENDS Naturals
1295VARIABLES screen, charges
1296vars == <<screen, charges>>
1297Init == screen = "Cart" /\\ charges = 0
1298Pay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED charges
1299Charge == ${charge}
1300Refund == charges = 2 /\\ charges' = 1 /\\ screen' = "Refunded"
1301Next == Pay \\/ Charge \\/ Refund
1302Spec == Init /\\ [][Next]_vars
1303NoDoubleCharge == charges <= 1
1304====
1305`;
1306const payMap = `export const readerActions = ["Pay"];
1307export const state = (v) => v.screen + ["", ", charged", ", charged twice"][v.charges];
1308export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you", Refund: "the bank refunds you" })[action] ?? "";
1309export const cause = () => "each charge request charges again";
1310`;
1311
1312test(
1313 "from-spec checks each PROPERTY in its own TLC run, and names the counterexample of a broken one",
1314 needsTlc,
1315 () => {
1316 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1317 writeFileSync(
1318 join(dir, "Pay.tla"),
1319 pay("Pay", `screen = "Paying" /\\ charges = 0 /\\ charges' = 1 /\\ UNCHANGED screen`).replace(
1320 "====",
1321 'ChargedAtMostOnce == [](charges <= 1)\nEventuallyPaying == <>(screen = "Paying")\n====',
1322 ),
1323 );
1324 writeFileSync(
1325 join(dir, "Pay.cfg"),
1326 "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nPROPERTIES ChargedAtMostOnce EventuallyPaying\n",
1327 );
1328 writeFileSync(join(dir, "Pay.map.mjs"), payMap);
1329 const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
1330 assert.equal(r.status, 1, r.stderr);
1331 assert.equal(
1332 r.stdout.split("\n\n")[0],
1333 `TLC on Pay.tla with Pay.cfg:
1334 NoDoubleCharge holds.
1335 ChargedAtMostOnce holds.
1336 EventuallyPaying is broken. The chart draws only INVARIANT rules, so read TLC's counterexample in ${join("Pay-statechart", "tlc", "Pay-EventuallyPaying.out")}.
1337 TLC ran 4 times in N s.`,
1338 );
1339 assert.match(r.stdout, /\nNo state breaks a rule\.\n/);
1340 const tlcDir = join(dir, "Pay-statechart", "tlc");
1341 assert.match(
1342 readFileSync(join(tlcDir, "Pay-EventuallyPaying.out"), "utf8"),
1343 /^Error: Temporal properties were violated\./m,
1344 );
1345 assert.deepEqual(
1346 readdirSync(tlcDir)
1347 .filter((f) => f.endsWith(".cfg") && /PROPERT/.test(readFileSync(join(tlcDir, f), "utf8")))
1348 .sort(),
1349 ["Pay-ChargedAtMostOnce.cfg", "Pay-EventuallyPaying.cfg"],
1350 );
1351 const expected = node(
1352 "statechart.mjs",
1353 ["from-spec", "Pay.tla", "--expect-broken", "EventuallyPaying"],
1354 dir,
1355 );
1356 assert.equal(expected.status, 0, expected.stderr);
1357 assert.match(expected.stdout, /\n {2}EventuallyPaying is broken \(expected\)\. /);
1358 },
1359);
1360
1361test(
1362 "from-spec checks a rule that the config lists as both PROPERTY and INVARIANT once, as the INVARIANT, and charts its bug",
1363 needsTlc,
1364 () => {
1365 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1366 writeFileSync(
1367 join(dir, "Pay.tla"),
1368 pay(
1369 "Pay",
1370 `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1371 ),
1372 );
1373 writeFileSync(
1374 join(dir, "Pay.cfg"),
1375 "SPECIFICATION Spec\nPROPERTY NoDoubleCharge\nINVARIANT NoDoubleCharge\n",
1376 );
1377 writeFileSync(join(dir, "Pay.map.mjs"), payMap);
1378 const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
1379 assert.equal(r.status, 1, r.stderr);
1380 assert.equal(
1381 r.stdout.split("\n\n")[0],
1382 `TLC on Pay.tla with Pay.cfg:
1383 NoDoubleCharge is broken.
1384 TLC ran 3 times in N s.`,
1385 );
1386 assert.match(r.stdout, /\nRule broken: `NoDoubleCharge`\n/);
1387 assert.equal(
1388 r.stdout.split("\n").at(-2),
1389 "Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Pay-statechart.",
1390 );
1391 const unknown = node(
1392 "statechart.mjs",
1393 ["from-spec", "Pay.tla", "--expect-broken", "Nope"],
1394 dir,
1395 );
1396 assert.match(unknown.stderr, /, whose rules are NoDoubleCharge\.\n/);
1397 },
1398);
1399
1400test("from-spec reports a broken PROPERTY in each form that TLC reports it in", needsTlc, () => {
1401 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1402 writeFileSync(
1403 join(dir, "Pay.tla"),
1404 pay(
1405 "Pay",
1406 `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1407 ).replace(
1408 "====",
1409 `ChargedAtMostOnce == [](charges <= 1)\nNeverChargedAgain == [][charges' <= 1]_charges\nStartsPaying == screen = "Paying"\n====`,
1410 ),
1411 );
1412 writeFileSync(
1413 join(dir, "Pay.cfg"),
1414 "SPECIFICATION Spec\nPROPERTIES ChargedAtMostOnce NeverChargedAgain StartsPaying\n",
1415 );
1416 writeFileSync(join(dir, "Pay.map.mjs"), payMap);
1417 const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
1418 assert.equal(r.status, 1, r.stderr);
1419 const broken = (p) =>
1420 ` ${p} is broken. The chart draws only INVARIANT rules, so read TLC's counterexample in ${join("Pay-statechart", "tlc", `Pay-${p}.out`)}.`;
1421 assert.equal(
1422 r.stdout.split("\n\n")[0],
1423 `TLC on Pay.tla with Pay.cfg:
1424 Pay.cfg has no INVARIANT, so the chart marks no state broken.
1425${broken("ChargedAtMostOnce")}
1426${broken("NeverChargedAgain")}
1427${broken("StartsPaying")}
1428 TLC ran 4 times in N s.`,
1429 );
1430});
1431
1432test(
1433 "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step",
1434 needsTlc,
1435 () => {
1436 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1437 const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n";
1438 writeFileSync(
1439 join(dir, "Pay.tla"),
1440 pay(
1441 "Pay",
1442 `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1443 ),
1444 );
1445 writeFileSync(
1446 join(dir, "PayFixed.tla"),
1447 pay(
1448 "PayFixed",
1449 `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`,
1450 ),
1451 );
1452 writeFileSync(join(dir, "Pay.cfg"), cfg);
1453 writeFileSync(join(dir, "PayFixed.cfg"), cfg);
1454 writeFileSync(join(dir, "pay.map.mjs"), payMap);
1455 assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/);
1456 assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/);
1457
1458 const statusOf = (chart) => {
1459 const names = new Map(
1460 [...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [
1461 m[2],
1462 m[1].replace(/<br\/>/g, " "),
1463 ]),
1464 );
1465 return Object.fromEntries(
1466 [...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]]),
1467 );
1468 };
1469 const args = [
1470 "--dot",
1471 "Pay.dot",
1472 "--map",
1473 "pay.map.mjs",
1474 "--traces",
1475 "Pay.out",
1476 "--highlight",
1477 "0",
1478 "--out",
1479 "chart.mmd",
1480 ];
1481 assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0);
1482 assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), {
1483 Cart: "works",
1484 Paying: "works",
1485 "Paying, charged": "works",
1486 "Paying, charged twice": "wrong",
1487 "Refunded, charged": "wrong",
1488 });
1489
1490 const withFix = [
1491 ...args,
1492 "--fixed-dot",
1493 "PayFixed.dot",
1494 "--fixed-traces",
1495 "PayFixed.out",
1496 "--fixed-rules",
1497 "NoDoubleCharge",
1498 ];
1499 assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0);
1500 const chart = readFileSync(join(dir, "chart.mmd"), "utf8");
1501 assert.deepEqual(statusOf(chart), {
1502 Cart: "works",
1503 "Paid, charged": "fixed",
1504 Paying: "works",
1505 "Paying, charged": "works",
1506 "Paying, charged twice": "fixed",
1507 "Refunded, charged": "fixed",
1508 });
1509 assert.deepEqual(
1510 [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]),
1511 ["Works today", "Fixed or new with the fix"],
1512 );
1513 assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/m);
1514 const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) =>
1515 t.label.startsWith("with the fix, "),
1516 );
1517 assert.deepEqual(
1518 fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`),
1519 ["Paying, charged --> Paid, charged : with the fix, the bank charges you"],
1520 );
1521 },
1522);
1523
1524test(
1525 "a rule that the second model does not check reads not checked, and a rule it checks reads unchanged",
1526 needsTlc,
1527 () => {
1528 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1529 writeFileSync(
1530 join(dir, "Pay.tla"),
1531 pay(
1532 "Pay",
1533 `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1534 ),
1535 );
1536 writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n");
1537 writeFileSync(join(dir, "pay.map.mjs"), payMap);
1538 assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/);
1539 const story = (...extra) => {
1540 const r = node(
1541 "tlc-to-chart.mjs",
1542 [
1543 ...["--dot", "Pay.dot", "--map", "pay.map.mjs", "--traces", "Pay.out"],
1544 ...["--against-dot", "Pay.dot", ...extra, "--story", "story.md", "--out", "chart.mmd"],
1545 ],
1546 dir,
1547 );
1548 assert.equal(r.status, 0, r.stderr);
1549 return readFileSync(join(dir, "story.md"), "utf8");
1550 };
1551 assert.match(
1552 story(),
1553 /^In the spec you see the same screens and steps, but the spec does not check this rule\.$/m,
1554 );
1555 assert.match(
1556 story("--against-traces", "Pay.out", "--against-rules", "NoDoubleCharge"),
1557 /^In the spec you see the same screens and steps, so the spec does not change this path\.$/m,
1558 );
1559 const alone = node(
1560 "tlc-to-chart.mjs",
1561 [
1562 "--dot",
1563 "Pay.dot",
1564 "--map",
1565 "pay.map.mjs",
1566 "--against-dot",
1567 "Pay.dot",
1568 "--against-traces",
1569 "Pay.out",
1570 ],
1571 dir,
1572 );
1573 assert.equal(alone.status, 2);
1574 assert.match(alone.stderr, /^--against-traces and --against-rules go together\./);
1575 },
1576);
1577
1578test(
1579 "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has",
1580 needsTlc,
1581 () => {
1582 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1583 writeFileSync(
1584 join(dir, "Pay.tla"),
1585 pay(
1586 "Pay",
1587 `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1588 ).replace("Next ==", 'Wait == screen = "Paying" /\\ UNCHANGED vars\nNext =='),
1589 );
1590 writeFileSync(
1591 join(dir, "Pay.cfg"),
1592 "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n",
1593 );
1594 const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, {
1595 TLA2TOOLS: join(dir, "no-tlc.jar"),
1596 });
1597 assert.equal(first.status, 2);
1598 assert.equal(first.stdout, "");
1599 assert.equal(
1600 first.stderr,
1601 "Wrote Pay.map.mjs. Fill in its labels, then run this command again.\n",
1602 );
1603 const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8");
1604 assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m);
1605 assert.match(
1606 scaffold,
1607 /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\tWait: "Wait",\n\};$/m,
1608 );
1609 assert.equal(existsSync(join(dir, "Pay-statechart")), false);
1610
1611 writeFileSync(
1612 join(dir, "Pay.map.mjs"),
1613 `${payMap}export const detail = (v) => \`charges = \${v.charges}\`;\n`,
1614 );
1615 const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
1616 assert.equal(r.status, 1, r.stderr);
1617 assert.equal(
1618 r.stdout,
1619 `TLC on Pay.tla with Pay.cfg:
1620 NoDoubleCharge is broken.
1621 TLC ran 3 times in N s.
1622
1623Chart: 5 states and 4 transitions from 5 model states.
16243 work, 2 break a rule.
1625
1626## 1. Paying, charged twice
1627
1628Rule broken: \`NoDoubleCharge\`
1629Why: each charge request charges again.
1630This is the only way to get here in the model.
1631
16320. Start: Cart
16331. You pay. Now: Paying
16342. The bank charges you. Now: Paying, charged
16353. The bank charges you. Now: Paying, charged twice
1636
1637Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, summary.json in Pay-statechart.
1638`,
1639 );
1640 assert.match(
1641 readFileSync(join(dir, "Pay-statechart", "bug-1-layered.mmd"), "utf8"),
1642 /state "Paying, charged twice" as g\d \{\n {4}state "charges = 2" as s\d\n/,
1643 );
1644 assert.deepEqual(readdirSync(join(dir, "Pay-statechart")).sort(), [
1645 "bug-1-developer.mmd",
1646 "bug-1-layered.mmd",
1647 "bug-1.mmd",
1648 "chart.mmd",
1649 "stories.md",
1650 "summary.json",
1651 "tlc",
1652 ]);
1653 assert.equal(
1654 readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"),
1655 "SPECIFICATION Spec\n",
1656 );
1657 assert.equal(
1658 readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"),
1659 "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n",
1660 );
1661 },
1662);
1663
1664test("a rule broken by a start state marks that state wrong", needsTlc, () => {
1665 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1666 writeFileSync(
1667 join(dir, "Start.tla"),
1668 `---- MODULE Start ----
1669VARIABLES conn, badge
1670vars == <<conn, badge>>
1671Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced"
1672Drop == conn = "Online" /\\ conn' = "Offline" /\\ badge' = "Waiting"
1673Next == Drop
1674Spec == Init /\\ [][Next]_vars
1675NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced")
1676====
1677`,
1678 );
1679 writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n");
1680 writeFileSync(
1681 join(dir, "start.map.mjs"),
1682 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n',
1683 );
1684 assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/);
1685 const args = [
1686 "--dot",
1687 "Start.dot",
1688 "--map",
1689 "start.map.mjs",
1690 "--traces",
1691 "Start.out",
1692 "--summary",
1693 "summary.json",
1694 "--out",
1695 "chart.mmd",
1696 ];
1697 const r = node("tlc-to-chart.mjs", args, dir);
1698 assert.equal(r.status, 0, r.stderr);
1699 const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8"));
1700 assert.deepEqual(
1701 summary.classes.map((c) => [c.end, c.readerSteps]),
1702 [["Offline | Synced", 0]],
1703 );
1704 assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 });
1705});
1706
1707const edit = (name, fixed) => `---- MODULE ${name} ----
1708VARIABLES s, flashed
1709vars == <<s, flashed>>
1710Init == s = "Idle" /\\ flashed = FALSE
1711Tap == s = "Idle" /\\ s' = "Saving" /\\ UNCHANGED flashed
1712Lose == s = "Saving" /\\ s' = "Lost edit" /\\ UNCHANGED flashed
1713Flash == s = "Saving" /\\ s' = "Flash" /\\ flashed' = ${fixed ? "TRUE" : "FALSE"}
1714Back == s \\in {"Lost edit", "Flash"} /\\ s' = "Idle" /\\ flashed' = FALSE
1715Next == Tap \\/ Flash \\/ Back${fixed ? "" : " \\/ Lose"}
1716Spec == Init /\\ [][Next]_vars
1717NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed)
1718====
1719`;
1720
1721test(
1722 "a bug that only the fixed model has is numbered after the current bugs and drawn wrong",
1723 needsTlc,
1724 () => {
1725 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1726 const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n";
1727 for (const [name, fixed] of [
1728 ["Edit", false],
1729 ["EditFixed", true],
1730 ]) {
1731 writeFileSync(join(dir, `${name}.tla`), edit(name, fixed));
1732 writeFileSync(join(dir, `${name}.cfg`), cfg);
1733 assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/);
1734 }
1735 writeFileSync(
1736 join(dir, "edit.map.mjs"),
1737 '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',
1738 );
1739 const args = [
1740 "--dot",
1741 "Edit.dot",
1742 "--map",
1743 "edit.map.mjs",
1744 "--traces",
1745 "Edit.out",
1746 "--fixed-dot",
1747 "EditFixed.dot",
1748 "--fixed-traces",
1749 "EditFixed.out",
1750 "--fixed-rules",
1751 "NoLostEdit",
1752 ];
1753 const r = node(
1754 "tlc-to-chart.mjs",
1755 [
1756 ...args,
1757 "--cover",
1758 "--highlight",
1759 "0",
1760 "--story",
1761 "story.md",
1762 "--summary",
1763 "summary.json",
1764 "--out",
1765 "chart.mmd",
1766 ],
1767 dir,
1768 );
1769 assert.equal(r.status, 0, r.stderr);
1770 assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/);
1771 const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8"));
1772 assert.deepEqual(
1773 summary.classes.map((c) => [c.n, c.end, c.fix]),
1774 [
1775 [1, "Lost edit", "Saving"],
1776 [2, "Flash", "new with the fix"],
1777 ],
1778 );
1779 assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 });
1780 assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m);
1781 const two = node(
1782 "tlc-to-chart.mjs",
1783 [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"],
1784 dir,
1785 );
1786 assert.equal(two.status, 0, two.stderr);
1787 assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/);
1788 },
1789);
1790
1791function flatEdges(mmd) {
1792 const names = new Map();
1793 const members = new Map();
1794 let box = null;
1795 for (const line of mmd.split("\n")) {
1796 const opens = /^ {2}state "([^"]+)" as (g\d+) \{$/.exec(line);
1797 const state = /^ +state "([^"]+)" as (s\d+)$/.exec(line);
1798 const said = (s) => s.replaceAll("<br/>", " ");
1799 if (opens) {
1800 box = { id: opens[2], name: said(opens[1]) };
1801 members.set(box.id, []);
1802 } else if (line === " }") box = null;
1803 else if (state) {
1804 names.set(state[2], box ? `${box.name}: ${said(state[1])}` : said(state[1]));
1805 if (box) members.get(box.id).push(state[2]);
1806 }
1807 }
1808 return [...mmd.matchAll(/^ {2}([gs]\d+) --> (s\d+) : (.*)$/gm)]
1809 .flatMap(([, from, to, label]) =>
1810 (members.get(from) ?? [from]).map((f) => `${names.get(f)} --> ${names.get(to)} : ${label}`),
1811 )
1812 .sort();
1813}
1814
1815test(
1816 "states of one context sit in one box, and an arrow from the box's border stands for the same step from each",
1817 needsTlc,
1818 () => {
1819 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1820 writeFileSync(
1821 join(dir, "Window.tla"),
1822 `---- MODULE Window ----
1823VARIABLES where, reply
1824vars == <<where, reply>>
1825Init == where = "Starting" /\\ reply = "none"
1826Boot == where = "Starting" /\\ where' = "Chat open" /\\ UNCHANGED reply
1827Ask == where = "Chat open" /\\ reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where
1828Answer == reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where
1829Close == where = "Chat open" /\\ where' = "Window closed" /\\ UNCHANGED reply
1830Open == where = "Window closed" /\\ where' = "Chat open" /\\ reply' = "none"
1831Next == Boot \\/ Ask \\/ Answer \\/ Close \\/ Open
1832Spec == Init /\\ [][Next]_vars
1833NoReplyWhileClosed == ~(where = "Window closed" /\\ reply = "shown")
1834====
1835`,
1836 );
1837 writeFileSync(join(dir, "Window.cfg"), "SPECIFICATION Spec\nINVARIANT NoReplyWhileClosed\n");
1838 writeFileSync(
1839 join(dir, "window.map.mjs"),
1840 `const R = { none: "no question", pending: "reply on its way", shown: "reply shown" };
1841export const state = (v) => \`\${v.where}: \${v.where === "Starting" ? "loading" : R[v.reply]}\`;
1842export 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];
1843`,
1844 );
1845 assert.match(tlc(dir, "Window").stdout, /Invariant NoReplyWhileClosed is violated/);
1846 const args = ["--dot", "Window.dot", "--map", "window.map.mjs", "--traces", "Window.out"];
1847 const draw = (extra, out) => {
1848 const r = node("tlc-to-chart.mjs", [...args, ...extra, "--out", out], dir);
1849 assert.equal(r.status, 0, r.stderr);
1850 return readFileSync(join(dir, out), "utf8");
1851 };
1852 const boxed = draw([], "boxed.mmd");
1853 const flat = draw(["--flat"], "flat.mmd");
1854
1855 assert.match(
1856 boxed,
1857 /^ {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,
1858 );
1859 assert.deepEqual(
1860 boxed.split("\n").filter((l) => /you open the window/.test(l)),
1861 [" g1 --> s0 : you open the window"],
1862 );
1863 assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3);
1864 assert.doesNotMatch(flat, /as g\d+ \{/);
1865 assert.deepEqual(flatEdges(boxed), flatEdges(flat));
1866 assert.match(boxed, /^ {2}class s5 wrongPath$/m);
1867 assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m);
1868 assert.match(boxed, /state "Breaks a rule" as legendWrong/);
1869 },
1870);
1871
1872const save = (name, code) => `---- MODULE ${name} ----
1873VARIABLES screen, note${code ? ", retries" : ""}
1874vars == <<screen, note${code ? ", retries" : ""}>>
1875Init == screen = "Editing" /\\ note = [text |-> "draft"${code ? `, rev |-> 0] /\\ retries = 0` : "]"}
1876Save == screen = "Editing" /\\ screen' = "Saving" /\\ UNCHANGED <<note${code ? ", retries" : ""}>>
1877Confirm == screen = "Saving" /\\ screen' = "Saved" /\\ note' = [text |-> "stored"${code ? `, rev |-> 1] /\\ UNCHANGED retries` : "]"}
1878Edit == screen = "Saved" /\\ screen' = "Editing" /\\ note' = [note EXCEPT !.text = "draft"]${code ? " /\\ retries' = 0" : ""}
1879${
1880 code
1881 ? `Retry == screen = "Saving" /\\ retries = 0 /\\ retries' = 1 /\\ UNCHANGED <<screen, note>>
1882TimeOut == screen = "Saving" /\\ retries = 1 /\\ screen' = "Saved" /\\ UNCHANGED <<note, retries>>
1883Next == Save \\/ Retry \\/ Confirm \\/ TimeOut \\/ Edit`
1884 : "Next == Save \\/ Confirm \\/ Edit"
1885}
1886Spec == Init /\\ [][Next]_vars
1887TypeOK == screen \\in {"Editing", "Saving", "Saved"}
1888\\* You never see "Saved" while the note is still a draft.
1889NoFalseSaved == ~(screen = "Saved" /\\ note.text = "draft")
1890====
1891`;
1892
1893test(
1894 "from-spec --against compares the code with the spec on the parts both have, and --expect-broken sets the exit code",
1895 needsTlc,
1896 () => {
1897 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1898 writeFileSync(join(dir, "SaveCode.tla"), save("SaveCode", true));
1899 writeFileSync(join(dir, "SaveAgreed.tla"), save("SaveAgreed", false));
1900 writeFileSync(
1901 join(dir, "SaveCode.cfg"),
1902 "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\n",
1903 );
1904 writeFileSync(
1905 join(dir, "SaveCode.map.mjs"),
1906 `export const readerActions = ["Save", "Edit"];
1907export const state = (v) => \`\${v.screen}, \${v.note.text}\`;
1908export 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] ?? "";
1909export const cause = () => "the app stops after one retry and shows Saved anyway";
1910`,
1911 );
1912 const base = [
1913 "from-spec",
1914 "SaveCode.tla",
1915 "--against",
1916 "SaveAgreed.tla",
1917 "--label",
1918 "the agreed chart",
1919 ];
1920 const noCfg = node("statechart.mjs", base, dir);
1921 assert.equal(noCfg.status, 2);
1922 assert.equal(
1923 noCfg.stderr,
1924 "SaveAgreed.cfg does not exist. Pass --against-config with the config to check SaveAgreed.tla with. The configs beside it: SaveCode.cfg.\n",
1925 );
1926 const run = (...extra) =>
1927 node("statechart.mjs", [...base, "--against-config", "SaveCode.cfg", ...extra], dir);
1928
1929 const unknown = run("--expect-broken", "NoLostNote");
1930 assert.equal(unknown.status, 2);
1931 assert.equal(
1932 unknown.stderr,
1933 "--expect-broken NoLostNote: not a rule in SaveCode.cfg, whose rules are TypeOK, NoFalseSaved.\n",
1934 );
1935
1936 const expected = run("--expect-broken", "NoFalseSaved");
1937 assert.equal(expected.status, 0, expected.stderr);
1938 assert.equal(
1939 expected.stdout,
1940 `TLC on SaveCode.tla with SaveCode.cfg:
1941 TypeOK holds.
1942 NoFalseSaved is broken (expected).
1943 TLC ran 4 times in N s.
1944TLC on SaveAgreed.tla with SaveCode.cfg:
1945 TypeOK holds.
1946 NoFalseSaved holds.
1947 TLC ran 3 times in N s.
1948
1949Chart: 4 states and 4 transitions from 10 model states.
19503 work, 1 breaks a rule, 0 only in the agreed chart.
1951
1952## 1. Saved, draft
1953
1954Rule broken: You never see "Saved" while the note is still a draft.
1955Why: the app stops after one retry and shows Saved anyway.
1956There are 2 ways to get here in the model. This is the shortest.
1957
19580. Start: Editing, draft
19591. You press Save. Now: Saving, draft
19602. The save times out. Now: Saved, draft
1961
1962In the agreed chart, step 2 (the save times out) cannot happen, so you stay at: Saving, draft.
1963
1964Broken as expected: NoFalseSaved.
1965
1966Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart.
1967`,
1968 );
1969 const uncitedWarning = `SaveCode.tla: TypeOK holds, but a rule can also hold because the model waits for a condition that the code does not check. No comment gives the file and line of the code that checks these conditions:
1970 SaveCode.tla:8 Retry: screen = "Saving"
1971 SaveCode.tla:9 TimeOut: retries = 1
1972Put that file and line in a comment on each condition, as in \\* pay.ts:120, or remove the condition when no code checks it.
1973`;
1974 const warnings = `No config beside SaveAgreed.tla has an "\\* expect:" line for SaveAgreed, so the stories cannot say whether SaveAgreed already lists each bug. When SaveAgreed.tla is a copy, pass the spec in its own folder.
1975${uncitedWarning}`;
1976 assert.equal(expected.stderr, warnings);
1977 const out = join(dir, "SaveCode-statechart");
1978 const chart = readFileSync(join(out, "chart.mmd"), "utf8");
1979 assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m);
1980 assert.deepEqual(
1981 [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]),
1982 ["Works", "Breaks a rule"],
1983 );
1984 assert.match(
1985 chart,
1986 /^ {2}s3 --> s3 : in the agreed chart, the save<br\/>times out cannot happen$/m,
1987 );
1988 const bug = readFileSync(join(out, "bug-1.mmd"), "utf8");
1989 assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [
1990 "Editing, draft",
1991 "Saved, draft",
1992 "Saving, draft",
1993 ]);
1994 const summary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8"));
1995 assert.deepEqual(summary.second.role, "against");
1996 assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]);
1997 assert.equal(summary.classes[0].against, "Saving, draft");
1998 assert.deepEqual(summary.codeLike, []);
1999 assert.deepEqual(summary.uncited, {
2000 "SaveCode.tla": [
2001 { line: 8, action: "Retry", condition: 'screen = "Saving"' },
2002 { line: 9, action: "TimeOut", condition: "retries = 1" },
2003 ],
2004 });
2005 assert.equal(summary.second.listed, undefined);
2006
2007 const unexpected = run();
2008 assert.equal(unexpected.status, 1, unexpected.stderr);
2009 const holds = run("--expect-broken", "TypeOK");
2010 assert.equal(holds.status, 1, holds.stderr);
2011 assert.match(
2012 holds.stdout,
2013 /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/,
2014 );
2015 const listed = run("--expect-broken", "NoFalseSaved,TypeOK");
2016 assert.equal(listed.status, 1, listed.stderr);
2017 assert.match(listed.stdout, /\nExpected to break but holds: TypeOK\.\n/);
2018 assert.doesNotMatch(listed.stdout, /Broken but not expected/);
2019
2020 writeFileSync(
2021 join(dir, "code.map.mjs"),
2022 readFileSync(join(out, "../SaveCode.map.mjs"), "utf8").replace(
2023 '"the app stops after one retry and shows Saved anyway"',
2024 '"retryCount hit maxRetries"',
2025 ),
2026 );
2027 const coded = run("--map", "code.map.mjs", "--expect-broken", "NoFalseSaved");
2028 assert.equal(coded.status, 0, coded.stderr);
2029 assert.match(
2030 coded.stdout,
2031 /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/,
2032 );
2033
2034 writeFileSync(
2035 join(dir, "rev.map.mjs"),
2036 readFileSync(join(dir, "SaveCode.map.mjs"), "utf8").replace(
2037 "${v.note.text}`",
2038 "${v.note.text} r${v.note.rev}`",
2039 ),
2040 );
2041 const lacks = run("--map", "rev.map.mjs", "--expect-broken", "NoFalseSaved");
2042 assert.equal(lacks.status, 2);
2043 assert.equal(
2044 lacks.stderr,
2045 `${warnings}rev.map.mjs: the agreed chart has no note.rev, and the mapping reads v.note.rev on its states. Label states and steps only from what both models have, or test "rev" in v.note first.\n`,
2046 );
2047 writeFileSync(
2048 join(dir, "rev.map.mjs"),
2049 readFileSync(join(dir, "SaveCode.map.mjs"), "utf8").replace(
2050 "${v.note.text}`",
2051 '${v.note.text}${"rev" in v.note ? ` r${v.note.rev}` : ""}`',
2052 ),
2053 );
2054 const tested = run("--map", "rev.map.mjs", "--expect-broken", "NoFalseSaved");
2055 assert.equal(tested.status, 0, tested.stderr);
2056
2057 writeFileSync(
2058 join(dir, "SaveBoth.cfg"),
2059 "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\nPROPERTY NoFalseSaved\n",
2060 );
2061 const both = node(
2062 "statechart.mjs",
2063 [
2064 ...base,
2065 "--config",
2066 "SaveBoth.cfg",
2067 "--against-config",
2068 "SaveBoth.cfg",
2069 "--expect-broken",
2070 "NoFalseSaved",
2071 ],
2072 dir,
2073 );
2074 assert.equal(both.status, 0, both.stderr);
2075 assert.equal(both.stderr, warnings);
2076
2077 writeFileSync(join(dir, "SaveAgreedType.cfg"), "SPECIFICATION Spec\nINVARIANT TypeOK\n");
2078 const unchecked = node(
2079 "statechart.mjs",
2080 [...base, "--against-config", "SaveAgreedType.cfg", "--expect-broken", "NoFalseSaved"],
2081 dir,
2082 );
2083 assert.equal(unchecked.status, 0, unchecked.stderr);
2084 assert.match(
2085 unchecked.stdout,
2086 /^TLC on SaveAgreed\.tla with SaveAgreedType\.cfg:\n {2}TypeOK holds\.\n/m,
2087 );
2088 assert.equal(
2089 unchecked.stderr,
2090 `SaveAgreedType.cfg does not check NoFalseSaved, so the stories cannot say whether SaveAgreed.tla keeps it.\n${warnings}`,
2091 );
2092
2093 const listedCfg = (rule) =>
2094 writeFileSync(
2095 join(dir, "SaveAgreedBug.cfg"),
2096 `\\* module: SaveAgreed\n\\* expect: ${rule}\nSPECIFICATION Spec\nINVARIANT ${rule}\n`,
2097 );
2098 listedCfg("NoFalseSaved");
2099 const known = run("--expect-broken", "NoFalseSaved");
2100 assert.equal(known.status, 0, known.stderr);
2101 assert.equal(known.stderr, uncitedWarning);
2102 assert.match(
2103 known.stdout,
2104 /\nWhy: .*\nListed: SaveAgreedBug\.cfg, a config of SaveAgreed\.tla, expects TLC to break this rule\.\n/,
2105 );
2106 const listedSummary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8"));
2107 assert.deepEqual(listedSummary.second.listed, {
2108 spec: "SaveAgreed.tla",
2109 breaks: { "SaveAgreedBug.cfg": "NoFalseSaved" },
2110 });
2111 assert.deepEqual(listedSummary.classes[0].listed, ["SaveAgreedBug.cfg"]);
2112 listedCfg("TypeOK");
2113 const fresh = run("--expect-broken", "NoFalseSaved");
2114 assert.equal(fresh.status, 0, fresh.stderr);
2115 assert.match(
2116 fresh.stdout,
2117 /\nNot listed: no config of SaveAgreed\.tla expects TLC to break this rule\. Its configs expect TLC to break `TypeOK`\.\n/,
2118 );
2119 },
2120);
2121
2122test(
2123 "from-chart checks a chart whose regions guard each other, and draws it back unchanged",
2124 needsTlc,
2125 () => {
2126 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2127 writeFileSync(join(dir, "lamp.mmd"), lamp);
2128 const r = node(
2129 "statechart.mjs",
2130 ["from-chart", "lamp.mmd", "--expect-broken", "LitWhileOff"],
2131 dir,
2132 );
2133 assert.equal(r.status, 0, r.stdout + r.stderr);
2134 assert.match(r.stdout, /\nThe spec draws lamp\.mmd back unchanged\.\n/);
2135 },
2136);
2137
2138const doorSpec = `---- MODULE Door ----
2139VARIABLES where, reply
2140vars == <<where, reply>>
2141Init == where = "Open" /\\ reply = "none"
2142Ask == reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where
2143Answer == where = "Open" /\\ reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where
2144Drop == where = "Closed" /\\ reply = "pending" /\\ reply' = "lost" /\\ UNCHANGED where
2145Close == where = "Open" /\\ where' = "Closed" /\\ UNCHANGED reply
2146Reopen == where = "Closed" /\\ where' = "Open" /\\ (reply' = reply \\/ (reply = "pending" /\\ reply' = "shown"))
2147Next == Ask \\/ Answer \\/ Drop \\/ Close \\/ Reopen
2148Spec == Init /\\ [][Next]_vars
2149NoLostReply == reply # "lost"
2150NoShownWhileClosed == ~(where = "Closed" /\\ reply = "shown")
2151====
2152`;
2153
2154const doorMap = `const W = { Open: "Window open", Closed: "Window closed" };
2155const R = { none: "no question", pending: "reply on its way", shown: "reply shown", lost: "reply lost" };
2156export const regionNames = ["Where you are", "Your reply"];
2157export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`;
2158export 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];
2159`;
2160
2161function runDoor(map) {
2162 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2163 writeFileSync(join(dir, "Door.tla"), doorSpec);
2164 writeFileSync(join(dir, "Door.map.mjs"), map);
2165 const out = join(dir, "Door-statechart");
2166 const read = (f) => readFileSync(join(out, f), "utf8").replaceAll("<br/>", " ");
2167 return (rule) => {
2168 writeFileSync(join(dir, "Door.cfg"), `SPECIFICATION Spec\nINVARIANT ${rule}\n`);
2169 const r = node("statechart.mjs", ["from-spec", "Door.tla", "--expect-broken", rule], dir);
2170 assert.equal(r.status, 0, r.stdout + r.stderr);
2171 const every = join(out, "tlc", "regions-all.mmd");
2172 return {
2173 stdout: r.stdout,
2174 chart: read("chart.mmd"),
2175 every: existsSync(every) ? read("tlc/regions-all.mmd") : null,
2176 bug: read("bug-1.mmd"),
2177 out,
2178 };
2179 };
2180}
2181
2182test(
2183 "from-spec draws parallel regions only when they combine back into TLC's graph, and boxes otherwise",
2184 needsTlc,
2185 () => {
2186 const run = runDoor(doorMap);
2187 const proved = run("NoLostReply");
2188 assert.match(
2189 proved.stdout,
2190 /\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/,
2191 );
2192 const lines = proved.chart.split("\n");
2193 assert.ok(lines.includes(' state "Where you are" as region0 {'));
2194 assert.ok(lines.includes(" --"));
2195 const guarded = lines.findIndex((l) => /: the reply arrives \[when Window open\]$/.test(l));
2196 assert.match(lines[guarded + 1], /^ {6}%% when r0s\d$/);
2197 assert.doesNotMatch(proved.chart, /^ {6}r1s\d --> r1s\d : you open the window/m);
2198 assert.equal(
2199 proved.every
2200 .split("\n")
2201 .filter((l) => /^ {6}(r1s\d) --> \1 : you open the window \[when Window closed\]$/.test(l))
2202 .length,
2203 1,
2204 );
2205 const again = node(
2206 "compare-charts.mjs",
2207 ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
2208 proved.out,
2209 );
2210 assert.equal(again.stdout, "same\n", again.stderr);
2211
2212 const boxed = run("NoShownWhileClosed");
2213 assert.equal(boxed.every, null);
2214 assert.match(
2215 boxed.stdout,
2216 /\nIt has no parallel regions, because "Window closed: reply shown" is "Breaks a rule", and its two region states would show "Works"\.\n/,
2217 );
2218 assert.doesNotMatch(boxed.chart, /^ +--$/m);
2219 assert.match(boxed.chart, /^ {2}state "Window closed" as g\d \{$/m);
2220 },
2221);
2222
2223test(
2224 "the mapping's guard words show on an arrow only when every model step behind it has them",
2225 needsTlc,
2226 () => {
2227 const run = runDoor(
2228 `${doorMap}export const guard = (a, before) =>
2229 ({ Answer: "window open", Drop: "window closed", Ask: before.where === "Open" ? "window open" : "" })[a] ?? "";
2230`,
2231 );
2232 const proved = run("NoLostReply");
2233 const lines = proved.chart.split("\n");
2234 for (const words of [
2235 /: the reply arrives \[window open\]$/,
2236 /: the app drops the reply \[window closed\]$/,
2237 ])
2238 assert.match(lines[lines.findIndex((l) => words.test(l)) + 1], /^ {6}%% when r0s\d$/);
2239 assert.ok(lines.some((l) => /^ {6}r1s\d --> r1s\d : you ask$/.test(l)));
2240 const again = node(
2241 "compare-charts.mjs",
2242 ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
2243 proved.out,
2244 );
2245 assert.equal(again.stdout, "same\n", again.stderr);
2246 assert.match(proved.bug, / : \d\. the app drops the reply \[window closed\]$/m);
2247
2248 const boxed = run("NoShownWhileClosed");
2249 assert.match(boxed.chart, / : you ask \[window open\]$/m);
2250 assert.match(boxed.chart, / : you ask$/m);
2251 },
2252);
2253
2254test(
2255 "the mapping's timer() writes after N s on timer steps, and refuses a value that is not seconds",
2256 needsTlc,
2257 () => {
2258 const timed = runDoor(
2259 `${doorMap}export const timer = (a) => (a === "Drop" ? 30 : undefined);\n`,
2260 )("NoLostReply");
2261 assert.match(timed.chart, / : after 30 s, the app drops the reply \[when Window closed\]$/m);
2262 assert.match(timed.bug, / : \d\. after 30 s, the app drops the reply$/m);
2263 const again = node(
2264 "compare-charts.mjs",
2265 ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
2266 timed.out,
2267 );
2268 assert.equal(again.stdout, "same\n", again.stderr);
2269
2270 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2271 writeFileSync(join(dir, "Door.tla"), doorSpec);
2272 writeFileSync(join(dir, "Door.cfg"), "SPECIFICATION Spec\nINVARIANT NoLostReply\n");
2273 writeFileSync(join(dir, "Door.map.mjs"), `${doorMap}export const timer = () => "soon";\n`);
2274 const r = node("statechart.mjs", ["from-spec", "Door.tla"], dir);
2275 assert.notEqual(r.status, 0);
2276 assert.match(
2277 r.stderr,
2278 /timer\(\) must return a number of seconds above 0, or nothing; got "soon" for \w+/,
2279 );
2280 },
2281);
2282
2283const groupMap = `${doorMap}export const group = (v) =>
2284 ({ none: "Nothing to show", pending: "Waiting", shown: "Reply shown", lost: "Reply shown" })[v.reply];
2285export const groupColors = { "Reply shown": "#d1f2eb", Waiting: null, "Never drawn": "#ffffff" };
2286`;
2287
2288test(
2289 "group() fills each state with its group's color, and the legend lists only the groups the chart draws",
2290 needsTlc,
2291 () => {
2292 const run = runDoor(groupMap)("NoLostReply");
2293 const lines = run.chart.split("\n");
2294 assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [
2295 "---",
2296 "config:",
2297 " markdownAutoWrap: false",
2298 " state:",
2299 " useMaxWidth: false",
2300 "---",
2301 "stateDiagram-v2",
2302 " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631",
2303 " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c",
2304 " classDef group0 fill:#d1f2eb,stroke:#697976,color:#1b2631",
2305 " classDef group0Wrong fill:#d1f2eb,stroke:#c0392b,stroke-width:4px,color:#7b241c",
2306 " classDef group1 fill:#d6eaf8,stroke:#6b757c,color:#1b2631",
2307 " classDef group2 fill:#fdebd0,stroke:#7f7668,color:#1b2631",
2308 ' state "Legend" as legend {',
2309 ' state "Reply shown" as legendGroup0',
2310 ' state "Waiting" as legendGroup1',
2311 ' state "Nothing to show" as legendGroup2',
2312 ' state "Breaks a rule" as legendWrong',
2313 " }",
2314 " class legendGroup0 group0",
2315 " class legendGroup1 group1",
2316 " class legendGroup2 group2",
2317 " class legendWrong plainWrong",
2318 ]);
2319 assert.ok(lines.includes(' state "reply lost" as r1s1'));
2320 assert.deepEqual(
2321 lines.filter((l) => l.startsWith(" class r")),
2322 [
2323 " class r0s0 plain",
2324 " class r0s1 plain",
2325 " class r1s0 group2",
2326 " class r1s1 group0Wrong",
2327 " class r1s2 group1",
2328 " class r1s3 group0",
2329 ],
2330 );
2331 assert.match(run.stdout, /\n5 work, 1 breaks a rule\.\n/);
2332 const again = node(
2333 "compare-charts.mjs",
2334 ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
2335 run.out,
2336 );
2337 assert.equal(again.stdout, "same\n", again.stderr);
2338
2339 const bug = run.bug.split("\n");
2340 assert.ok(
2341 bug.includes(
2342 " classDef group1Path fill:#d6eaf8,stroke:#1f3a93,stroke-width:4px,color:#1b2631",
2343 ),
2344 );
2345 assert.deepEqual(
2346 bug.filter((l) => /^ {2}class s\d/.test(l)),
2347 [
2348 " class s0 group0Wrong",
2349 " class s1 group1Path",
2350 " class s2 group2Path",
2351 " class s3 group1Path",
2352 ],
2353 );
2354
2355 const summary = JSON.parse(readFileSync(join(run.out, "summary.json"), "utf8"));
2356 assert.deepEqual(summary.chartGroups, [
2357 { name: "Reply shown", color: "#d1f2eb", states: 2 },
2358 { name: "Waiting", color: "#d6eaf8", states: 1 },
2359 { name: "Nothing to show", color: "#fdebd0", states: 1 },
2360 ]);
2361 assert.deepEqual(summary.mixedGroups, []);
2362 },
2363);
2364
2365test(
2366 "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",
2367 needsTlc,
2368 () => {
2369 const run = runDoor(groupMap)("NoLostReply");
2370 const tlcDir = join(run.out, "tlc");
2371 const chart = (map, ...extra) => {
2372 writeFileSync(join(tlcDir, "probe.map.mjs"), map);
2373 return node(
2374 "tlc-to-chart.mjs",
2375 [
2376 "--dot",
2377 "Door.dot",
2378 "--map",
2379 "probe.map.mjs",
2380 "--traces",
2381 "Door-NoLostReply.out",
2382 "--highlight",
2383 "0",
2384 ...extra,
2385 ],
2386 tlcDir,
2387 );
2388 };
2389
2390 const hidden = chart(
2391 `${doorMap}export const group = () => "All good";\nexport const groupColors = { "All good": "#e9f7ef" };\n`,
2392 "--regions",
2393 );
2394 assert.equal(hidden.status, 0, hidden.stderr);
2395 const lines = hidden.stdout.split("\n");
2396 assert.ok(
2397 lines.includes(
2398 " classDef group0Wrong fill:#e9f7ef,stroke:#c0392b,stroke-width:4px,color:#7b241c",
2399 ),
2400 );
2401 const legend = lines.indexOf(' state "Legend" as legend {');
2402 assert.deepEqual(lines.slice(legend, legend + 6), [
2403 ' state "Legend" as legend {',
2404 ' state "All good" as legendGroup0',
2405 ' state "Breaks a rule" as legendWrong',
2406 " }",
2407 " class legendGroup0 group0",
2408 " class legendWrong plainWrong",
2409 ]);
2410 assert.ok(lines.includes(" class r1s1 group0Wrong"));
2411 assert.equal(lines.filter((l) => / group0Wrong$/.test(l)).length, 1);
2412
2413 const coarse = chart(
2414 `const W = { Open: "Window open", Closed: "Window closed" };
2415export const state = (v) => W[v.where];
2416export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting");
2417`,
2418 "--summary",
2419 "coarse.json",
2420 );
2421 assert.equal(coarse.status, 0, coarse.stderr);
2422 assert.deepEqual(
2423 coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)),
2424 [
2425 " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631",
2426 " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c",
2427 " class legendWrong plainWrong",
2428 " class s0 plainWrong",
2429 " class s1 plain",
2430 ],
2431 );
2432 const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8"));
2433 assert.deepEqual(summary.chartGroups, []);
2434 assert.deepEqual(summary.mixedGroups, ["Window closed", "Window open"]);
2435
2436 for (const [map, error] of [
2437 [
2438 `export const group = (v) => (v.reply === "lost" ? "Breaks a rule" : "Fine");`,
2439 /group "Breaks a rule" has the name of a status; TLC decides the status, so name the group for what the reader sees/,
2440 ],
2441 [`export const group = () => "fixed";`, /group "fixed" has the name of a status/],
2442 [
2443 `export const group = () => "All good";\nexport const groupColors = { "All good": "#123" };`,
2444 /groupColors\["All good"\] "#123" is too dark for the status borders to show on it/,
2445 ],
2446 [
2447 `export const group = () => "All good";\nexport const groupColors = { "All good": "red" };`,
2448 /groupColors\["All good"\] "red" is not "#rgb" or "#rrggbb"/,
2449 ],
2450 [
2451 `export const group = () => "All good";\nexport const groupColors = ["#ffffff"];`,
2452 /groupColors must be an object from a group name to a color such as #d6eaf8/,
2453 ],
2454 [`export const group = () => 1;`, /group\(\) must return a group name or nothing; got 1/],
2455 ]) {
2456 const r = chart(`${doorMap}${map}\n`, "--regions");
2457 assert.equal(r.status, 1, map);
2458 assert.equal(r.stdout, "", map);
2459 assert.match(r.stderr, error);
2460 }
2461 },
2462);
2463
2464const order = (name, note, charge) => `---- MODULE ${name} ----
2465EXTENDS Naturals
2466VARIABLES screen, charges, note
2467vars == <<screen, charges, note>>
2468Init == screen = "Cart" /\\ charges = 0 /\\ note = 0
2469Pay == screen = "Cart" /\\ screen' = "Paying" /\\ note' = ${note} /\\ UNCHANGED charges
2470Charge == screen = "Paying" /\\ charges < 2 /\\ ${charge}
2471Next == Pay \\/ Charge
2472Spec == Init /\\ [][Next]_vars
2473NoDoubleCharge == charges <= 1
2474====
2475`;
2476const chargeAgain = "charges' = charges + 1 /\\ UNCHANGED <<screen, note>>";
2477const chargeOnce = `IF charges = 0 THEN charges' = 1 /\\ UNCHANGED <<screen, note>> ELSE screen' = "Paid" /\\ UNCHANGED <<charges, note>>`;
2478const chargeFirstOnly = "charges = 0 /\\ charges' = 1 /\\ UNCHANGED <<screen, note>>";
2479
2480test(
2481 "the fix changes a bug only at the first step that the reader sees differently",
2482 needsTlc,
2483 () => {
2484 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2485 writeFileSync(join(dir, "Order.tla"), order("Order", 0, chargeAgain));
2486 writeFileSync(join(dir, "Order.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n");
2487 const withFix = (charge, map = payMap) => {
2488 writeFileSync(join(dir, "Order.map.mjs"), map);
2489 writeFileSync(join(dir, "OrderFixed.tla"), order("OrderFixed", 1, charge));
2490 const r = node(
2491 "statechart.mjs",
2492 ["from-spec", "Order.tla", "--fixed", "OrderFixed.tla"],
2493 dir,
2494 );
2495 assert.equal(r.status, 1, r.stderr);
2496 const chart = readFileSync(join(dir, "Order-statechart", "chart.mmd"), "utf8");
2497 return {
2498 story: readFileSync(join(dir, "Order-statechart", "stories.md"), "utf8"),
2499 fixEdges: parseMermaid(chart)
2500 .regions.Main.transitions.filter((t) => t.label.includes("with the fix"))
2501 .map((t) => `${t.from} --> ${t.to} : ${t.label}`),
2502 };
2503 };
2504 const hidden = withFix(chargeAgain);
2505 assert.deepEqual(hidden.fixEdges, []);
2506 assert.match(
2507 hidden.story,
2508 /^With the fix you see the same screens and steps, so the fix does not change this path\.$/m,
2509 );
2510 const once = withFix(chargeOnce);
2511 assert.deepEqual(once.fixEdges, [
2512 "Paying, charged --> Paid, charged : with the fix, the bank charges you",
2513 ]);
2514 assert.match(
2515 once.story,
2516 /^With the fix, step 3 \(the bank charges you\) takes you to: Paid, charged\.$/m,
2517 );
2518 const unlabeledMap = payMap.replace(
2519 /^export const state = .*$/m,
2520 "export const state = (v) => v.screen;",
2521 );
2522 const unseen = withFix(chargeOnce, unlabeledMap);
2523 assert.deepEqual(unseen.fixEdges, ["Paying --> Paid : with the fix, the bank charges you"]);
2524 assert.match(
2525 unseen.story,
2526 /^With the fix, after step 1, "the bank charges you" happens and takes you to: Paid\.$/m,
2527 );
2528 const blocked = withFix(chargeFirstOnly, unlabeledMap);
2529 assert.deepEqual(blocked.fixEdges, []);
2530 assert.match(
2531 blocked.story,
2532 /yet the rule holds, so the state labels do not show what the fix changes/,
2533 );
2534 },
2535);
2536
2537const notify = (name, mutedArrive) => `---- MODULE ${name} ----
2538EXTENDS Naturals
2539VARIABLES screen, muted, banner, badge, missed, msgs, syncs
2540vars == <<screen, muted, banner, badge, missed, msgs, syncs>>
2541Init == screen = "Inbox" /\\ muted = FALSE /\\ banner = FALSE /\\ badge = FALSE
2542 /\\ missed = FALSE /\\ msgs = 0 /\\ syncs = 0
2543OpenThread == screen = "Inbox" /\\ screen' = "Thread" /\\ UNCHANGED <<muted, banner, badge, missed, msgs, syncs>>
2544OpenSettings == screen \\in {"Inbox", "Thread"} /\\ screen' = "Settings" /\\ UNCHANGED <<muted, banner, badge, missed, msgs, syncs>>
2545TapBack == screen \\in {"Thread", "Settings"} /\\ screen' = "Inbox" /\\ UNCHANGED <<muted, banner, badge, missed, msgs, syncs>>
2546Mute == screen = "Settings" /\\ ~muted /\\ muted' = TRUE /\\ UNCHANGED <<screen, banner, badge, missed, msgs, syncs>>
2547Refresh == syncs < 2 /\\ syncs' = syncs + 1 /\\ UNCHANGED <<screen, muted, banner, badge, missed, msgs>>
2548Arrive == msgs < syncs /\\ msgs' = msgs + 1
2549 /\\ (IF muted THEN ${mutedArrive} ELSE banner' = TRUE /\\ UNCHANGED <<badge, missed>>)
2550 /\\ UNCHANGED <<screen, muted, syncs>>
2551Next == OpenThread \\/ OpenSettings \\/ TapBack \\/ Mute \\/ Refresh \\/ Arrive
2552Spec == Init /\\ [][Next]_vars
2553NeverMissed == ~missed
2554NoBannerOnThread == ~(banner /\\ screen = "Thread")
2555====
2556`;
2557
2558test(
2559 "a step the reader does not see breaks the rule, and the fix shows it on another screen",
2560 needsTlc,
2561 () => {
2562 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2563 writeFileSync(
2564 join(dir, "Notify.tla"),
2565 notify("Notify", "missed' = TRUE /\\ UNCHANGED <<banner, badge>>"),
2566 );
2567 writeFileSync(
2568 join(dir, "NotifyBadge.tla"),
2569 notify("NotifyBadge", "badge' = TRUE /\\ UNCHANGED <<banner, missed>>"),
2570 );
2571 writeFileSync(
2572 join(dir, "Notify.cfg"),
2573 "SPECIFICATION Spec\nINVARIANT NeverMissed\nINVARIANT NoBannerOnThread\n",
2574 );
2575 writeFileSync(
2576 join(dir, "Notify.map.mjs"),
2577 `export const readerActions = ["OpenThread", "OpenSettings", "TapBack", "Mute", "Refresh"];
2578export const state = (v) => [v.screen, v.muted && "muted", v.banner && "banner", v.badge && "badge"].filter(Boolean).join(", ");
2579export 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];
2580export const cause = () => null;
2581`,
2582 );
2583 const r = node(
2584 "statechart.mjs",
2585 ["from-spec", "Notify.tla", "--fixed", "NotifyBadge.tla", "--label", "the badge fix"],
2586 dir,
2587 );
2588 assert.equal(r.status, 1, r.stderr);
2589 const out = (f) => readFileSync(join(dir, "Notify-statechart", f), "utf8");
2590 const fixEdges = (f) =>
2591 parseMermaid(out(f))
2592 .regions.Main.transitions.filter((t) => t.label.includes("with the badge fix"))
2593 .map((t) => `${t.from} --> ${t.to} : ${t.label}`);
2594 const { classes } = JSON.parse(out("summary.json"));
2595 assert.deepEqual(
2596 classes.map((c) => [c.invariant, c.end, c.fix]),
2597 [
2598 ["NoBannerOnThread", "Thread, banner", "unchanged"],
2599 ["NeverMissed", "Settings, muted", "Settings, muted, badge"],
2600 ["NeverMissed", "Inbox, muted", "Inbox, muted, badge"],
2601 ["NeverMissed", "Settings, muted, banner", "Settings, muted, banner, badge"],
2602 ["NeverMissed", "Thread, muted", "Thread, muted, badge"],
2603 ["NoBannerOnThread", "Thread, muted, banner", "unchanged"],
2604 ["NeverMissed", "Inbox, muted, banner", "Inbox, muted, banner, badge"],
2605 ["NeverMissed", "Thread, muted, banner", "Thread, muted, banner, badge"],
2606 ["NoBannerOnThread", "Thread, muted, banner, badge", "new with the badge fix"],
2607 ],
2608 );
2609 const missed = classes.filter((c) => c.invariant === "NeverMissed");
2610
2611 const story = out("stories.md");
2612 assert.doesNotMatch(story, /do not show what/);
2613 assert.match(
2614 story,
2615 /^With the badge fix, after step 3, "a message arrives" happens and takes you to: Settings, muted, badge\.$/m,
2616 );
2617 const settings = classes.find((c) => c.end === "Settings, muted");
2618 assert.deepEqual(fixEdges(`bug-${settings.n}.mmd`), [
2619 "Settings, muted --> Settings, muted, badge : with the badge fix, a message arrives",
2620 ]);
2621 const drawn = fixEdges("chart.mmd");
2622 assert.equal(new Set(drawn).size, drawn.length);
2623 for (const c of missed)
2624 assert.ok(
2625 drawn.includes(`${c.end} --> ${c.end}, badge : with the badge fix, a message arrives`),
2626 `bug ${c.n}`,
2627 );
2628 },
2629);
2630
2631test(
2632 "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",
2633 needsTlc,
2634 () => {
2635 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2636 writeFileSync(
2637 join(dir, "Browse.tla"),
2638 `---- MODULE Browse ----
2639EXTENDS Naturals
2640VARIABLES screen, looks, scrolled
2641vars == <<screen, looks, scrolled>>
2642Init == screen = "Cart" /\\ looks = 0 /\\ scrolled = FALSE
2643Look == screen = "Cart" /\\ looks < 3 /\\ looks' = looks + 1 /\\ UNCHANGED <<screen, scrolled>>
2644Scroll == screen = "Cart" /\\ looks = 1 /\\ ~scrolled /\\ scrolled' = TRUE /\\ UNCHANGED <<screen, looks>>
2645Pay == screen = "Cart" /\\ looks = 3 /\\ scrolled /\\ screen' = "Paying" /\\ UNCHANGED <<looks, scrolled>>
2646Fail == screen = "Paying" /\\ screen' = "Failed" /\\ UNCHANGED <<looks, scrolled>>
2647Hang == screen = "Paying" /\\ screen' = "Stuck" /\\ UNCHANGED <<looks, scrolled>>
2648Next == Look \\/ Scroll \\/ Pay \\/ Fail \\/ Hang
2649Spec == Init /\\ [][Next]_vars
2650NeverLost == screen \\notin {"Failed", "Stuck"}
2651====
2652`,
2653 );
2654 writeFileSync(join(dir, "Browse.cfg"), "SPECIFICATION Spec\nINVARIANT NeverLost\n");
2655 writeFileSync(
2656 join(dir, "Browse.map.mjs"),
2657 `export const readerActions = ["Look", "Scroll", "Pay"];
2658export const state = (v) => v.screen;
2659export const step = (a) => ({ Look: "you look", Scroll: "you scroll", Pay: "you pay", Fail: "the payment fails", Hang: "the payment hangs" })[a];
2660export const cause = () => null;
2661export const legend = { works: "Works on main", wrong: "Wrong on main" };
2662`,
2663 );
2664 const r = node("statechart.mjs", ["from-spec", "Browse.tla"], dir);
2665 assert.equal(r.status, 1, r.stderr);
2666 const out = (f) => readFileSync(join(dir, "Browse-statechart", f), "utf8");
2667 assert.deepEqual(
2668 JSON.parse(out("summary.json")).classes.map((c) => [c.end, c.cause]),
2669 [
2670 ["Failed", null],
2671 ["Stuck", null],
2672 ],
2673 );
2674 assert.doesNotMatch(out("stories.md"), /^Why:|null/m);
2675 const loops = (chart) => [...chart.matchAll(/^ {2}(s\d+) --> \1 : (.*)$/gm)].map((m) => m[2]);
2676 assert.deepEqual(loops(out("bug-1.mmd")), ["1. you look<br/>2. you scroll<br/>3, 4. you look"]);
2677 assert.deepEqual(loops(out("chart.mmd")), ["you look or you scroll"]);
2678 const chart = out("chart.mmd");
2679 assert.ok(chart.includes('state "Works on main" as legendWorks'));
2680 assert.ok(chart.includes('state "Wrong on main" as legendWrong'));
2681 assert.doesNotMatch(chart, /legendFixed/);
2682 },
2683);
2684
2685test("a rule that stays broken reports only the step that broke it", needsTlc, () => {
2686 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2687 writeFileSync(
2688 join(dir, "Buzz.tla"),
2689 `---- MODULE Buzz ----
2690EXTENDS Naturals
2691VARIABLES x, y, buzzed
2692vars == <<x, y, buzzed>>
2693Init == x = 0 /\\ y = 0 /\\ buzzed = FALSE
2694IncX == x < 6 /\\ x' = x + 1 /\\ UNCHANGED <<y, buzzed>>
2695IncY == y < 6 /\\ y' = y + 1 /\\ UNCHANGED <<x, buzzed>>
2696Ring == x = 2 /\\ ~buzzed /\\ buzzed' = TRUE /\\ UNCHANGED <<x, y>>
2697Next == IncX \\/ IncY \\/ Ring
2698Spec == Init /\\ [][Next]_vars
2699NeverBuzzed == ~buzzed
2700====
2701`,
2702 );
2703 writeFileSync(join(dir, "Buzz.cfg"), "SPECIFICATION Spec\nINVARIANT NeverBuzzed\n");
2704 writeFileSync(
2705 join(dir, "Buzz.map.mjs"),
2706 'export const state = (v) => (v.buzzed ? "Buzzed" : "Quiet");\nexport const step = () => "time passes";\n',
2707 );
2708 const r = node("statechart.mjs", ["from-spec", "Buzz.tla"], dir);
2709 assert.equal(r.status, 1, r.stderr);
2710 const traces = parseTraces(
2711 readFileSync(join(dir, "Buzz-statechart", "tlc", "Buzz-NeverBuzzed.out"), "utf8"),
2712 );
2713 assert.equal(traces.length, 7);
2714 for (const t of traces)
2715 assert.equal(
2716 t.steps.findIndex((s) => s.vars.buzzed),
2717 t.steps.length - 1,
2718 );
2719});
2720
2721test("from-spec warns when steps carry the name of the whole next-state action", needsTlc, () => {
2722 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2723 const count = (next) => `---- MODULE Count ----
2724EXTENDS Naturals
2725VARIABLES x
2726Init == x = 0
2727Inc == x < 3 /\\ x' = x + 1
2728${next}
2729Spec == \\* not [][Inc]_x
2730 Init /\\ [][Next]_x
2731====
2732`;
2733 writeFileSync(join(dir, "Count.cfg"), "SPECIFICATION Spec\n");
2734 writeFileSync(join(dir, "Count.map.mjs"), "export const state = (v) => String(v.x);\n");
2735 writeFileSync(join(dir, "Count.tla"), count("Next == Inc \\/ (x = 3 /\\ x' = 0)"));
2736 const unnamed = node("statechart.mjs", ["from-spec", "Count.tla"], dir);
2737 assert.equal(unnamed.status, 0, unnamed.stderr);
2738 assert.match(
2739 unnamed.stderr,
2740 /^Count\.tla: 1 step has the name Next, the whole next-state action/,
2741 );
2742 writeFileSync(join(dir, "Count.tla"), count("Next == Inc"));
2743 const named = node("statechart.mjs", ["from-spec", "Count.tla"], dir);
2744 assert.equal(named.status, 0, named.stderr);
2745 assert.equal(named.stderr, "");
2746});
2747
2748const writeLamp = (dir) => {
2749 writeFileSync(
2750 join(dir, "Lamp.tla"),
2751 "---- MODULE Lamp ----\nVARIABLE on\nInit == on = FALSE\nOn == on' = TRUE\nOff == on' = FALSE\nSpec == Init /\\ [][On \\/ Off]_on\n====\n",
2752 );
2753 writeFileSync(join(dir, "Lamp.cfg"), "SPECIFICATION Spec\n");
2754 writeFileSync(
2755 join(dir, "Lamp.map.mjs"),
2756 'export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "you flip the switch";\n',
2757 );
2758 return join(dir, "Lamp-statechart");
2759};
2760
2761test("a rerun without --png removes the chart.png of an earlier run", needsTlc, () => {
2762 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2763 const out = writeLamp(dir);
2764 mkdirSync(out);
2765 writeFileSync(join(out, "chart.png"), "");
2766 const r = node("statechart.mjs", ["from-spec", "Lamp.tla"], dir);
2767 assert.equal(r.status, 0, r.stderr);
2768 assert.deepEqual(
2769 readdirSync(out).filter((f) => f.endsWith(".png")),
2770 [],
2771 );
2772});
2773
2774const writeSlowPay = (dir, ticks, paid = "Paid") => {
2775 writeFileSync(
2776 join(dir, "Pay.tla"),
2777 `---- MODULE Pay ----
2778EXTENDS Naturals
2779VARIABLES screen, charges, ticks
2780vars == <<screen, charges, ticks>>
2781Init == screen = "Cart" /\\ charges = 0 /\\ ticks = 0
2782TapPay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED <<charges, ticks>>
2783Charge == screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED <<screen, ticks>>
2784Done == screen = "Paying" /\\ charges > 0 /\\ screen' = "${paid}" /\\ UNCHANGED <<charges, ticks>>
2785Tick == ticks < ${ticks} /\\ ticks' = ticks + 1 /\\ UNCHANGED <<screen, charges>>
2786Spec == Init /\\ [][TapPay \\/ Charge \\/ Done \\/ Tick]_vars
2787ChargedAtMostTwice == charges <= 2
2788====
2789`,
2790 );
2791 writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT ChargedAtMostTwice\n");
2792 writeFileSync(
2793 join(dir, "Pay.map.mjs"),
2794 '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',
2795 );
2796 return join(dir, "Pay-statechart");
2797};
2798const startRun = (dir, { wrap = [], env = {}, args = ["from-spec", "Pay.tla"] } = {}) => {
2799 const [command, ...rest] = [...wrap, process.execPath, join(scripts, "statechart.mjs"), ...args];
2800 const child = spawn(command, rest, {
2801 cwd: dir,
2802 env: { ...process.env, ...env },
2803 stdio: ["ignore", "ignore", "pipe"],
2804 detached: true,
2805 });
2806 let stderr = "";
2807 child.stderr.on("data", (d) => {
2808 stderr += d;
2809 });
2810 return {
2811 child,
2812 done: new Promise((done) => child.on("close", (status) => done({ status, stderr }))),
2813 };
2814};
2815const until = async (ready) => {
2816 while (!ready()) await sleep(20);
2817};
2818const ownTmp = (dir, name) => {
2819 const tmp = join(dir, name);
2820 mkdirSync(tmp);
2821 return tmp;
2822};
2823const tlcStarted = (tmp) => readdirSync(tmp).some((f) => f.startsWith("tlc-"));
2824const staged = (dir) => readdirSync(dir).filter((f) => f.startsWith("."));
2825
2826test(
2827 "a run whose Node is killed mid-TLC leaves the output folder as it was, and a rerun charts its own spec",
2828 needsTlc,
2829 async () => {
2830 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2831 const out = writeSlowPay(dir, 60000);
2832 const tmp = ownTmp(dir, "tmp");
2833 const first = startRun(dir, { env: { TMPDIR: tmp } });
2834 await until(() => tlcStarted(tmp));
2835 await sleep(300);
2836 first.child.kill("SIGTERM");
2837 await first.done;
2838 assert.doesNotThrow(() => process.kill(-first.child.pid, 0), "TLC outlives its Node");
2839 assert.equal(existsSync(out), false);
2840
2841 writeSlowPay(dir, 200, "Receipt");
2842 const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
2843 try {
2844 process.kill(-first.child.pid, "SIGKILL");
2845 } catch (error) {
2846 if (error.code !== "ESRCH") throw error;
2847 }
2848 assert.equal(second.status, 0, second.stderr);
2849 for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) {
2850 const text = readFileSync(join(out, file), "utf8");
2851 assert.match(text, /Receipt/);
2852 assert.doesNotMatch(text, /Paid/);
2853 }
2854 assert.deepEqual(staged(dir), []);
2855 },
2856);
2857
2858const changedMidRun = async ({ wrap = [], env = {} }) => {
2859 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2860 const out = writeSlowPay(dir, 20000);
2861 const tmp = ownTmp(dir, "tmp-a");
2862 const first = startRun(dir, { wrap, env: { ...env, TMPDIR: tmp } });
2863 await until(() => tlcStarted(tmp));
2864 await sleep(500);
2865 writeSlowPay(dir, 20000, "Receipt");
2866 const second = startRun(dir, { wrap, env: { ...env, TMPDIR: ownTmp(dir, "tmp-b") } });
2867 const [paid, receipt] = await Promise.all([first.done, second.done]);
2868 assert.equal(receipt.status, 0, receipt.stderr);
2869 assert.equal(paid.status, 2, paid.stderr);
2870 const [, kept] = paid.stderr.match(
2871 /^Pay\.tla changed while this run read it, so the run left Pay-statechart as it was\. Run the command again\. The files that this run wrote are in (tmp-a\/statechart-\w+)\.\n$/,
2872 );
2873 assert.match(readFileSync(join(dir, kept, "chart.mmd"), "utf8"), /Paid/);
2874 const chart = readFileSync(join(out, "chart.mmd"), "utf8");
2875 assert.match(chart, /Receipt/);
2876 assert.doesNotMatch(chart, /Paid/);
2877 assert.deepEqual(staged(dir), []);
2878};
2879
2880test(
2881 "a run whose spec changes while it reads it leaves the output folder, and the run that read the new spec charts it",
2882 needsTlc,
2883 () => changedMidRun({}),
2884);
2885
2886const unshare = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"];
2887const hidePidNamespace = `#define _GNU_SOURCE
2888#include <dlfcn.h>
2889#include <errno.h>
2890#include <string.h>
2891#include <unistd.h>
2892ssize_t readlink(const char *path, char *buf, size_t size) {
2893 static ssize_t (*real)(const char *, char *, size_t);
2894 if (!real) real = dlsym(RTLD_NEXT, "readlink");
2895 if (path && strcmp(path, "/proc/self/ns/pid") == 0) { errno = EIO; return -1; }
2896 return real(path, buf, size);
2897}
2898`;
2899const hidingPreload = (() => {
2900 if (spawnSync(unshare[0], [...unshare.slice(1), "true"]).status !== 0) return null;
2901 const dir = mkdtempSync(join(tmpdir(), "statechart-preload-"));
2902 writeFileSync(join(dir, "hide.c"), hidePidNamespace);
2903 const so = join(dir, "hide.so");
2904 const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "hide.c"), "-ldl"]);
2905 return cc.status === 0 ? so : null;
2906})();
2907test(
2908 "runs in PID namespaces that hide which namespace they are in chart only their own spec",
2909 {
2910 skip:
2911 needsTlc.skip ||
2912 (!hidingPreload && "cannot make a PID namespace, or cannot compile a readlink preload"),
2913 },
2914 () => changedMidRun({ wrap: unshare, env: { LD_PRELOAD: hidingPreload } }),
2915);
2916
2917test(
2918 "a file that is put in the output folder during a run stays, and the run exits 2",
2919 needsTlc,
2920 async () => {
2921 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2922 const out = writeSlowPay(dir, 20000);
2923 const tmp = ownTmp(dir, "tmp");
2924 const run = startRun(dir, { env: { TMPDIR: tmp } });
2925 await until(() => tlcStarted(tmp));
2926 mkdirSync(out);
2927 writeFileSync(join(out, "notes.md"), "mine\n");
2928 const r = await run.done;
2929 assert.equal(r.status, 2);
2930 assert.match(
2931 r.stderr,
2932 /^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\. The files that this run wrote are in tmp\/statechart-\w+\.\n$/,
2933 );
2934 assert.deepEqual(readdirSync(out), ["notes.md"]);
2935 assert.deepEqual(staged(dir), []);
2936 },
2937);
2938
2939const failRenames = `#define _GNU_SOURCE
2940#include <dlfcn.h>
2941#include <errno.h>
2942#include <stdlib.h>
2943#include <string.h>
2944#include <unistd.h>
2945static size_t hits;
2946int rename(const char *from, const char *to) {
2947 static int (*real)(const char *, const char *);
2948 if (!real) real = dlsym(RTLD_NEXT, "rename");
2949 const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS");
2950 const char *cannot = getenv("RENAME_CANNOT");
2951 base = base ? base + 1 : to;
2952 if (cannot && strstr(base, "shared")) {
2953 errno = strcmp(cannot, "EXDEV") == 0 ? EXDEV : access(to, F_OK) == 0 ? EISDIR : ENOSYS;
2954 return -1;
2955 }
2956 if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) {
2957 errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO;
2958 return -1;
2959 }
2960 return real(from, to);
2961}
2962`;
2963const failingRenames = (() => {
2964 const dir = mkdtempSync(join(tmpdir(), "statechart-preload-"));
2965 writeFileSync(join(dir, "rename.c"), failRenames);
2966 const so = join(dir, "rename.so");
2967 const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "rename.c"), "-ldl"]);
2968 return cc.status === 0 ? so : null;
2969})();
2970test(
2971 "a run whose rename fails after it moves the output folder aside puts the folder back, or says where it is",
2972 { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") },
2973 () => {
2974 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2975 writeLamp(dir);
2976 const args = ["from-spec", "Lamp.tla", "--out", "shared"];
2977 const shared = join(dir, "shared");
2978 const first = node("statechart.mjs", args, dir);
2979 assert.equal(first.status, 0, first.stderr);
2980 const chart = readFileSync(join(shared, "chart.mmd"), "utf8");
2981 const run = (fails) =>
2982 node("statechart.mjs", args, dir, { LD_PRELOAD: failingRenames, RENAME_FAILS: fails });
2983
2984 const back = run("NI");
2985 assert.equal(back.status, 2, back.stderr);
2986 const [, kept] = back.stderr.match(
2987 /^cannot replace shared: EIO: .*\. The run left shared as it was\. The files that this run wrote are in (\/\S+)\.\n$/,
2988 );
2989 assert.equal(readFileSync(join(kept, "chart.mmd"), "utf8"), chart);
2990 assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart);
2991 assert.deepEqual(staged(dir), []);
2992
2993 const stuck = run("NII");
2994 assert.equal(stuck.status, 2, stuck.stderr);
2995 const named = stuck.stderr.match(
2996 /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\. The files that this run wrote are in \/\S+\.\n$/,
2997 );
2998 assert.ok(named, stuck.stderr);
2999 const aside = named[1];
3000 assert.equal(existsSync(shared), false);
3001 assert.equal(readFileSync(join(dir, aside, "chart.mmd"), "utf8"), chart);
3002 assert.deepEqual(staged(dir), [aside]);
3003 },
3004);
3005
3006test(
3007 "a run on a filesystem that cannot rename copies its files into place",
3008 { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") },
3009 () => {
3010 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3011 writeLamp(dir);
3012 const args = ["from-spec", "Lamp.tla", "--out", "shared"];
3013 for (const [cannot, label] of [
3014 ["ENOSYS", "you flip the switch"],
3015 ["EXDEV", "you press the switch"],
3016 ["ENOSYS", "you tap the switch"],
3017 ]) {
3018 writeFileSync(
3019 join(dir, "Lamp.map.mjs"),
3020 `export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "${label}";\n`,
3021 );
3022 const r = node("statechart.mjs", args, dir, {
3023 LD_PRELOAD: failingRenames,
3024 RENAME_CANNOT: cannot,
3025 });
3026 assert.equal(r.status, 0, r.stderr);
3027 assert.match(readFileSync(join(dir, "shared", "chart.mmd"), "utf8"), new RegExp(label));
3028 assert.deepEqual(staged(dir), []);
3029 }
3030 },
3031);
3032
3033test(
3034 "a run whose input changes before --png renders stops before it renders, and keeps its files",
3035 needsTlc,
3036 async () => {
3037 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3038 writeSlowPay(dir, 20000);
3039 const bin = ownTmp(dir, "bin");
3040 const rendered = join(dir, "rendered");
3041 writeFileSync(join(bin, "npx"), `#!/bin/sh\ntouch "${rendered}"\nexit 1\n`, { mode: 0o755 });
3042 const tmp = ownTmp(dir, "tmp");
3043 const run = startRun(dir, {
3044 args: ["from-spec", "Pay.tla", "--png"],
3045 env: { TMPDIR: tmp, PATH: `${bin}${delimiter}${process.env.PATH}` },
3046 });
3047 await until(() => tlcStarted(tmp));
3048 const map = join(dir, "Pay.map.mjs");
3049 writeFileSync(map, `${readFileSync(map, "utf8")}// edited\n`);
3050 const r = await run.done;
3051 assert.equal(r.status, 2, r.stderr);
3052 const [, kept] = r.stderr.match(
3053 /^Pay\.map\.mjs changed while this run read it, so the run left Pay-statechart as it was\. Run the command again\. The files that this run wrote are in (tmp\/statechart-\w+)\.\n$/,
3054 );
3055 assert.ok(existsSync(join(dir, kept, "chart.mmd")));
3056 assert.equal(existsSync(rendered), false);
3057 },
3058);
3059
3060test(
3061 "a rule that TLC finds broken before TLC throws a Java exception is broken, and the run draws the paths TLC printed",
3062 needsTlc,
3063 () => {
3064 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3065 writeFileSync(
3066 join(dir, "Pay.tla"),
3067 pay(
3068 "Pay",
3069 `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
3070 ),
3071 );
3072 writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n");
3073 writeFileSync(join(dir, "Pay.map.mjs"), payMap);
3074 writeFileSync(
3075 join(dir, "crash.txt"),
3076 `Error: Invariant NoDoubleCharge is violated.\n${tlcThrew(
3077 "java.lang.ArrayIndexOutOfBoundsException",
3078 "Index 123 out of bounds for length 4",
3079 ).replaceAll("Small", "NoDoubleCharge")}\n${stoppedEarly}`,
3080 );
3081 const java = join(dir, "java");
3082 writeFileSync(
3083 java,
3084 `#!/bin/sh
3085"$REAL_JAVA" "$@" > "$0.$$.out"; s=$?
3086if grep -q '^Error: Invariant' "$0.$$.out"; then
3087 sed '/^Model checking completed/,$d' "$0.$$.out"; cat "$CRASH"; s=75
3088else
3089 cat "$0.$$.out"
3090fi
3091rm "$0.$$.out"
3092exit $s
3093`,
3094 { mode: 0o755 },
3095 );
3096 const r = node(
3097 "statechart.mjs",
3098 ["from-spec", "Pay.tla", "--expect-broken", "NoDoubleCharge"],
3099 dir,
3100 { JAVA: java, TLA2TOOLS: tools.jar, REAL_JAVA: tools.java, CRASH: join(dir, "crash.txt") },
3101 );
3102 assert.equal(r.status, 0, r.stderr);
3103 assert.match(r.stdout, /^ {2}NoDoubleCharge is broken \(expected\)\.$/m);
3104 assert.match(
3105 r.stderr,
3106 /^Pay-statechart\/tlc\/Pay-NoDoubleCharge\.out: TLC found that NoDoubleCharge is broken, and then failed with java\.lang\.ArrayIndexOutOfBoundsException: Index 123 out of bounds for length 4\. .* Do not weaken NoDoubleCharge because of it\./m,
3107 );
3108 assert.match(r.stdout, /^## 1\. Paying, charged twice$/m);
3109 },
3110);
3111
3112test(
3113 "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other",
3114 needsTlc,
3115 () => {
3116 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3117 writeSlowPay(dir, 0);
3118 const tmp = ownTmp(dir, "tmp");
3119 const log = join(dir, "java.log");
3120 const java = join(dir, "java");
3121 writeFileSync(
3122 java,
3123 `#!/bin/sh
3124for a; do case "$a" in -Djava.io.tmpdir=*) t="\${a#-Djava.io.tmpdir=}";; esac; done
3125"$REAL_JAVA" "$@" > "$LOG.$$"; s=$?
3126cat "$LOG.$$"
3127echo "$t: $(grep 'Naturals.tla' "$LOG.$$")" >> "$LOG"
3128rm "$LOG.$$"
3129exit $s
3130`,
3131 { mode: 0o755 },
3132 );
3133 const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, {
3134 JAVA: java,
3135 TLA2TOOLS: tools.jar,
3136 REAL_JAVA: tools.java,
3137 LOG: log,
3138 TMPDIR: tmp,
3139 });
3140 assert.equal(r.status, 0, r.stderr);
3141 const runs = readFileSync(log, "utf8").trim().split("\n");
3142 assert.ok(runs.length > 0);
3143 const folders = new Set();
3144 for (const line of runs) {
3145 const [folder, parsed] = line.split(": ");
3146 assert.ok(folder.startsWith(join(tmp, "tlc-")), line);
3147 assert.ok(parsed.startsWith(`Parsing file ${folder}/`), line);
3148 folders.add(folder);
3149 }
3150 assert.equal(folders.size, runs.length);
3151 },
3152);
3153
3154test(
3155 "a run that TLC stops on an error keeps TLC's output and writes no output folder",
3156 needsTlc,
3157 () => {
3158 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3159 writeFileSync(
3160 join(dir, "Pay.tla"),
3161 "---- MODULE Pay ----\nVARIABLE x\nASSUME FALSE\nInit == x = 0\nSpec == Init /\\ [][x' = x]_x\n====\n",
3162 );
3163 writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n");
3164 writeFileSync(join(dir, "Pay.map.mjs"), 'export const state = () => "Cart";\n');
3165 const tmp = ownTmp(dir, "tmp");
3166 const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { TMPDIR: tmp });
3167 assert.equal(r.status, 2);
3168 const [, file] = r.stderr.match(
3169 /^(\S+\/tlc\/Pay\.out): TLC stopped on an error that is not a broken rule: Assumption .* is false\.\n$/,
3170 );
3171 assert.ok(file.startsWith(tmp), file);
3172 assert.match(readFileSync(file, "utf8"), /Assumption .* is false/);
3173 assert.equal(existsSync(join(dir, "Pay-statechart")), false);
3174 },
3175);
3176
3177test(
3178 "runs of two specs that overlap on one output folder all finish, and the folder always holds one run's files",
3179 needsTlc,
3180 async () => {
3181 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3182 const out = writeSlowPay(dir, 400);
3183 writeLamp(dir);
3184 const pay = ["from-spec", "Pay.tla", "--out", "shared"];
3185 const lamp = ["from-spec", "Lamp.tla", "--out", "shared"];
3186 const alone = {};
3187 for (const args of [pay, lamp]) {
3188 const solo = join(dir, `${args[1]}-alone`);
3189 const r = node("statechart.mjs", [...args.slice(0, 2), "--out", solo], dir);
3190 assert.equal(r.status, 0, r.stderr);
3191 alone[args[1]] = {
3192 top: readdirSync(solo).sort().join(),
3193 tlc: readdirSync(join(solo, "tlc")).sort().join(),
3194 chart: readFileSync(join(solo, "chart.mmd"), "utf8"),
3195 summary: readFileSync(join(solo, "summary.json"), "utf8"),
3196 };
3197 }
3198 const shared = join(dir, "shared");
3199 const seen = new Set();
3200 let watching = true;
3201 const watch = (async () => {
3202 while (watching) {
3203 try {
3204 seen.add(readdirSync(join(shared, "tlc")).sort().join());
3205 } catch (e) {
3206 if (e.code !== "ENOENT") throw e;
3207 }
3208 await sleep(5);
3209 }
3210 })();
3211 const runs = [];
3212 for (let i = 0; i < 6; i++) {
3213 runs.push(startRun(dir, { args: i % 2 ? lamp : pay }).done);
3214 await sleep(150);
3215 }
3216 const finished = await Promise.all(runs);
3217 watching = false;
3218 await watch;
3219 for (const r of finished) assert.equal(r.status, 0, r.stderr);
3220 const runsOf = new Map(Object.entries(alone).map(([spec, a]) => [a.tlc, spec]));
3221 for (const listing of seen) assert.ok(runsOf.has(listing), `tlc/ had ${listing}`);
3222 const last = alone[runsOf.get(readdirSync(join(shared, "tlc")).sort().join())];
3223 assert.equal(readdirSync(shared).sort().join(), last.top);
3224 assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), last.chart);
3225 assert.equal(readFileSync(join(shared, "summary.json"), "utf8"), last.summary);
3226 assert.equal(existsSync(out), false);
3227 assert.deepEqual(staged(dir), []);
3228 },
3229);
3230
3231test(
3232 "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",
3233 needsTlc,
3234 () => {
3235 const dir = mkdtempSync(join(tmpdir(), "statechart-"));
3236 const model = (ext) => readFileSync(join(scripts, "model", `OutputFolder${ext}`), "utf8");
3237 const r = node(
3238 "statechart.mjs",
3239 ["from-spec", join(scripts, "model", "OutputFolder.tla"), "--out", join(dir, "out")],
3240 dir,
3241 );
3242 assert.equal(r.status, 0, r.stdout + r.stderr);
3243 assert.match(r.stdout, /\nNo state breaks a rule\.\n/);
3244 for (const [i, [rule, guard, without, story]] of [
3245 [
3246 "ChartsOneSpec",
3247 "/\\ read' = Set(read, r, read[r] \\cup {spec})",
3248 "/\\ read' = [q \\in Runs |-> read[q] \\cup {spec}]",
3249 /You start run 2 in a second shell\..*\n.*Run 2 moves its folder into place\. Now: Two runs going: chart of both specs/,
3250 ],
3251 [
3252 "ChartsOneSpec",
3253 '/\\ pc[r] = "publish" /\\ spec = snap[r] /\\ ~out.notes',
3254 '/\\ pc[r] = "publish" /\\ ~out.notes',
3255 /You edit the spec\..*\n.*Run 1 moves its folder into place\. Now: One run going: chart of both specs/,
3256 ],
3257 [
3258 "KeepsYourFiles",
3259 '/\\ pc[r] = "aside" /\\ ~aside[r].notes\n',
3260 '/\\ pc[r] = "aside"\n',
3261 /Run 1 deletes the old folder and finishes\. Now: Done: chart of the spec\n/,
3262 ],
3263 [
3264 "NeverLosesChart",
3265 " /\\ \\/ /\\ aside[r].exists /\\ ~out.exists\n /\\ out' = aside[r]\n /\\ aside' = Set(aside, r, NoFolder)\n \\/ UNCHANGED <<out, aside>>\n",
3266 " /\\ out' = out\n /\\ aside' = Set(aside, r, IF aside[r].notes THEN aside[r] ELSE NoFolder)\n",
3267 /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/,
3268 ],
3269 [
3270 "NeverLosesChart",
3271 '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists /\\ ~out.notes',
3272 '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists',
3273 /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/,
3274 ],
3275 ].entries()) {
3276 const mutant = join(dir, `${i}-${rule}`);
3277 mkdirSync(mutant);
3278 assert.ok(model(".tla").includes(guard), guard);
3279 writeFileSync(join(mutant, "OutputFolder.tla"), model(".tla").replace(guard, without));
3280 for (const ext of [".cfg", ".map.mjs"])
3281 writeFileSync(join(mutant, `OutputFolder${ext}`), model(ext));
3282 const m = node(
3283 "statechart.mjs",
3284 ["from-spec", "OutputFolder.tla", "--expect-broken", rule],
3285 mutant,
3286 );
3287 assert.equal(m.status, 0, m.stdout + m.stderr);
3288 assert.match(m.stdout, story);
3289 }
3290 },
3291);
skills/statechart/scripts/test/sync.mmd+19−0
@@ -0,0 +1,19 @@
1stateDiagram-v2
2 state "Notes app" as App {
3 state "Connection" as Connection {
4 [*] --> Online
5 Online --> Offline : the network drops
6 Offline --> Online : the network returns
7 }
8 --
9 state "Sync badge" as Sync {
10 state "Synced" as Synced
11 state "Waiting to sync" as Pending
12 [*] --> Synced
13 Synced --> Pending : you edit a note
14 Synced --> Pending : the network drops
15 Pending --> Synced : the server confirms
16 }
17 }
18 [*] --> App
19 %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline.
skills/statechart/scripts/test/upload.mmd+16−0
@@ -0,0 +1,16 @@
1stateDiagram-v2
2 state "Nothing chosen" as Idle
3 state "Uploading" as Uploading {
4 state "Sending the file" as Sending
5 state "Checking the file" as Checking
6 [*] --> Sending
7 Sending --> Checking : the last byte arrives
8 }
9 state "Uploaded" as Done
10 state "Upload failed" as Failed
11 [*] --> Idle
12 Idle --> Uploading : you choose a file
13 Checking --> Done : the check passes
14 Uploading --> Failed : the network drops
15 Failed --> Uploading : you retry
16 Uploading --> Idle : you cancel
skills/statechart/scripts/tlc-to-chart.mjs+1428−0
@@ -0,0 +1,1428 @@
1#!/usr/bin/env node
2import fs from "node:fs";
3import path from "node:path";
4import process from "node:process";
5import { pathToFileURL } from "node:url";
6import {
7 FULL_LABELS,
8 GROUP_PALETTE,
9 LEGEND_ID,
10 PLAIN_FILL,
11 compareCharts,
12 escapeMermaidQuotes,
13 groupClassDef,
14 groupColorProblem,
15 isRecord,
16 joinAnd,
17 joinLabels,
18 parseDot,
19 parseMermaid,
20 parseTraces,
21 productOf,
22 ruleSentences,
23 stateKey,
24 tlcOutputWarning,
25 wrap,
26 STATUS_CLASSES,
27} from "./lib.mjs";
28
29const USAGE = "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]";
30const HELP = `${USAGE}
31
32Collapses a TLC state graph onto the reader's states and writes a Mermaid stateDiagram-v2.
33statechart.mjs runs it once for the overview and once for each bug chart.
34
35 --dot FILE The state graph from TLC -dump dot,actionlabels FILE, for the current code.
36 Required.
37 --map FILE The mapping module. Required. It exports state(vars, facts), and
38 optionally detail(vars, facts), step(action, before, after),
39 guard(action, before, after), timer(action, before, after),
40 cause(vars, facts, invariant), group(vars, facts), groupColors,
41 regionNames, readerActions, and legend. guard is when the step
42 can happen, in plain words. An edge reads "step [words]" when every
43 model step behind it has words. timer is how many seconds the code waits
44 before a timer step, and the step reads "after N s, step".
45 cause is the hidden reason a bad state is bad: counterexamples with the
46 same invariant and cause are one bug. When cause returns nothing, the
47 end state stands in for it. readerActions are the actions the
48 reader takes. group is the group of a state, below. legend, such as
49 { works: "Works on main" }, gives the legend's words for the works,
50 wrong, and fixed statuses.
51 facts.enabled is the set of actions the state enables. facts.settled is
52 true when only reader actions are enabled, so nothing else happens until
53 the reader acts.
54 --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one
55 per bad state. It refuses a file from a TLC run that stopped early or hit
56 an error other than a broken rule. When TLC found a rule broken and then
57 failed with a Java exception, it keeps the paths that TLC printed and
58 warns that the chart can miss states that break the rule.
59 --fixed-dot FILE The state graph of the model with the open fix. Each bug's steps replay
60 on it.
61 --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks
62 states fixed or still wrong, and adds the bugs that only the fixed model
63 has after the current model's bugs.
64 --fixed-rules LIST The invariants that the fixed model's TLC runs check, as in A,B. It goes
65 with --fixed-traces. A rule that the fixed model does not check stays
66 as wrong as it is today, and its stories say "not checked".
67 --against-dot FILE The state graph of the spec that the current code must follow.
68 Each bug's steps replay on it. Do not use it with --fixed-dot.
69 --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the
70 bugs that only the spec has number after the code's bugs.
71 --against-rules LIST The invariants that the spec's TLC runs check. It goes with
72 --against-traces. Stories of a rule that the spec does not check say
73 "not checked".
74 --listed JSON What the spec's configs expect, as in
75 {"spec":"Spec.tla","breaks":{"A.cfg":"Rule"}}: each config whose
76 "\\* expect:" line names a rule, and that rule. It goes with
77 --against-dot. Each story then says whether a config of the spec already
78 expects TLC to break its rule, and summary.json has second.listed and
79 each bug's listed configs.
80 --label TEXT The name of the second model in the chart and the stories. Default:
81 "the fix" with --fixed-dot, "the spec" with --against-dot.
82 --spec FILE A TLA+ file whose \\* comment lines above a rule are the rule's sentence in
83 the stories. Repeat it. The first file that has a sentence wins.
84 --view NAME customer (state labels, the default), developer (detail labels), or
85 layered (detail inside state).
86 --cover Keep only the shortest path to each settled reader state, plus the
87 counterexample paths.
88 --highlight N Draw bug N as a numbered path. Default 1. 0 draws none.
89 --path-only Draw only the highlighted path.
90 --full-labels Write each label on an edge in full, joined by " or ", and mark the chart
91 "${FULL_LABELS}". Default: write the leading words that the labels
92 share once, as in "you tap Pay or Back".
93 --flat Draw every state on its own, with no boxes.
94 --regions When every state reads "context: progress", draw two parallel regions,
95 context and progress, when they are smaller and proved. They draw the
96 steps of the shortest way to each region state, of each bug path, and
97 the nearest step with each label.
98 Otherwise draw the chart the other flags ask for, and print why.
99 Customer view, --highlight 0, no --flat or --path-only.
100 --proof FILE With --regions, write the flat chart that the proof compares with.
101 --regions-all FILE With --regions, also write the regions with every step.
102 --title TEXT The chart title.
103 --out FILE The Mermaid chart. Default: stdout.
104 --story FILE The reader stories, one per bug.
105 --summary FILE The counts for the chart and the bugs, as JSON.
106 -h, --help Print this help, and exit 0.
107
108Each bug replays on the second model by its screens and step words, which are what the reader
109sees. The replay takes the second model's steps that keep the screen and that the reader does not
110take. A bug step that the reader does not see must keep the screen on the second model too, unless
111the second model cannot take it. The replay stops at the first step that the second model shows
112the reader differently. There the chart draws "with LABEL, STEP" (or "in LABEL, STEP" for
113--against-dot) to where the second model goes, or a loop "... STEP cannot happen" when it cannot
114take a step that the reader sees.
115
116The second model may have other variables and actions than the current model. Variables and
117record fields that one start state has and the other does not are left out when the two models
118are compared. One mapping draws both models, so it may read only what both have. The command
119stops when the mapping reads a variable or field of the current model on a state of the second
120model that lacks it. Test "name" in v first to label such a state another way. A step of an
121action that the second model does not have is skipped when it changes no shared variable, and
122matches any step to the same shared state when it does.
123
124In the customer view, states whose labels read "context: progress" with the same context sit in
125one box named context, and each shows only its progress. A context with one state stays a plain
126state. An arrow from a box's border stands for the same step from every state in the box: it is
127drawn only when every state in the box takes that step, with the same labels, to the same state.
128The layered view puts each detail state in a box named by its state label.
129
130With --regions, each "context: progress" label is one state in the context region and one in the
131progress region. A step that happens beside only some states of the other region gets a guard:
132"[when A or B]" or "[unless A or B]", whichever is shorter, and a "%% when [not] ids" line under it.
133The mapping's guard words, when the step has them, replace "when A or B" in the label.
134The proof combines the regions with every step as compare-charts.mjs does, and needs exactly the
135flat chart's states, start states, and labeled steps. Each state's color must be the worse color
136of its two region states. Then the drawn arrows, combined, must make only steps of the flat chart:
137a step that they make wrongly gets back every arrow that the full regions take there.
138compare-charts.mjs on the --regions-all and --proof files repeats the first proof.
139mapping.regionNames = [context, progress] names the two regions.
140
141Outside regions, two states have at most one arrow. Its lines are the numbered steps, then the
142current model's steps, then the second model's steps, with a blank line between each group.
143
144Without group(), the legend shows only the statuses the chart uses. A chart with a second model
145has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. Each chart
146starts with front matter that keeps Mermaid from shrinking it to the page or rewrapping its labels.
147
148Groups: group(vars, facts) returns the name of the group that a model state is in, such as
149"Toast shown", or nothing. A chart state is in a group when all its model states are in it. A
150region state is in a group when every state that it is part of is in it. Each group fills its
151states with one color, and the legend lists only the groups that the chart draws: first the
152groups in groupColors, in that order, then the others from A to Z. groupColors, such as
153{ "Toast shown": "#d6eaf8" }, gives a group a light "#rgb" or "#rrggbb" color. A group with no
154color, or null, takes the next free palette color: light blue, orange, purple, yellow, teal, tan,
155then pink. A state in no group, or in more than one, is gray. summary.json counts the drawn
156states of each group in chartGroups, and lists the chart states with more than one in mixedGroups.
157TLC decides the status, and a group cannot change it. On top of the group color, a state that
158breaks a rule has a thick red border, and a fixed state or a state only in the second model a
159thick green border. The legend then shows that border. A thick blue border marks the bug path on
160states that work. On other states of the path, the numbered arrows show it. A group cannot have
161the name of a status, such as "Breaks a rule".
162
163Writes only the files that --out, --story, and --summary name.
164
165Exit codes
166 0 It wrote the chart.
167 1 It cannot use an input: a mapping without state(), a mapping that reads what the second
168 model lacks, TLC output that it refuses, or a --highlight with no such bug. The error says
169 why.
170 2 A bad argument.`;
171const more = "Run node tlc-to-chart.mjs --help for the flags.";
172
173const args = {
174 traces: [],
175 fixedTraces: [],
176 againstTraces: [],
177 fixedRules: [],
178 againstRules: [],
179 specs: [],
180 view: "customer",
181 highlight: 1,
182};
183for (let i = 2; i < process.argv.length; i++) {
184 const a = process.argv[i];
185 const next = () => process.argv[++i];
186 if (a === "-h" || a === "--help") {
187 console.log(HELP);
188 process.exit(0);
189 } else if (a === "--dot") args.dot = next();
190 else if (a === "--map") args.map = next();
191 else if (a === "--traces") args.traces.push(next());
192 else if (a === "--fixed-dot") args.fixedDot = next();
193 else if (a === "--fixed-traces") args.fixedTraces.push(next());
194 else if (a === "--fixed-rules") args.fixedRules.push(...next().split(","));
195 else if (a === "--against-dot") args.againstDot = next();
196 else if (a === "--against-traces") args.againstTraces.push(next());
197 else if (a === "--against-rules") args.againstRules.push(...next().split(","));
198 else if (a === "--listed") args.listed = next();
199 else if (a === "--label") args.label = next();
200 else if (a === "--spec") args.specs.push(next());
201 else if (a === "--view") args.view = next();
202 else if (a === "--highlight") args.highlight = Number(next());
203 else if (a === "--path-only") args.pathOnly = true;
204 else if (a === "--full-labels") args.fullLabels = true;
205 else if (a === "--flat") args.flat = true;
206 else if (a === "--regions") args.regions = true;
207 else if (a === "--proof") args.proof = next();
208 else if (a === "--regions-all") args.regionsAll = next();
209 else if (a === "--cover") args.cover = true;
210 else if (a === "--title") args.title = next();
211 else if (a === "--out") args.out = next();
212 else if (a === "--story") args.story = next();
213 else if (a === "--summary") args.summary = next();
214 else {
215 console.error(`unknown argument ${a}. ${more}`);
216 process.exit(2);
217 }
218}
219const fail = (message) => {
220 console.error(`${message} ${more}`);
221 process.exit(2);
222};
223if (!args.dot || !args.map) fail(USAGE);
224if (!["customer", "developer", "layered"].includes(args.view))
225 fail(`--view must be customer, developer, or layered, not ${args.view}.`);
226if (args.fixedDot && args.againstDot) fail("use --fixed-dot or --against-dot, not both.");
227if (args.fixedTraces.length && !args.fixedDot) fail("--fixed-traces needs --fixed-dot.");
228if (args.againstTraces.length && !args.againstDot) fail("--against-traces needs --against-dot.");
229if (!args.fixedTraces.length !== !args.fixedRules.length)
230 fail("--fixed-traces and --fixed-rules go together. The rules are what those TLC runs check.");
231if (!args.againstTraces.length !== !args.againstRules.length)
232 fail(
233 "--against-traces and --against-rules go together. The rules are what those TLC runs check.",
234 );
235if (args.listed !== undefined) {
236 if (!args.againstDot)
237 fail("--listed says what the spec's configs expect; it needs --against-dot.");
238 const shape = `--listed must be JSON such as {"spec":"Spec.tla","breaks":{"A.cfg":"Rule"}}`;
239 try {
240 args.listed = JSON.parse(args.listed);
241 } catch (e) {
242 fail(`${shape}: ${e.message}.`);
243 }
244 const { spec, breaks } = isRecord(args.listed) ? args.listed : {};
245 if (
246 typeof spec !== "string" ||
247 !isRecord(breaks) ||
248 Object.values(breaks).some((r) => typeof r !== "string")
249 )
250 fail(`${shape}.`);
251}
252if (args.label !== undefined && !args.fixedDot && !args.againstDot)
253 fail("--label names the second model; it needs --fixed-dot or --against-dot.");
254if (args.proof && !args.regions) fail("--proof writes the regions proof; it needs --regions.");
255if (args.regionsAll && !args.regions)
256 fail("--regions-all writes every regions arrow; it needs --regions.");
257if (
258 args.regions &&
259 (args.view !== "customer" || args.flat || args.pathOnly || args.highlight !== 0)
260)
261 fail(
262 "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only.",
263 );
264
265const role = args.fixedDot ? "fix" : args.againstDot ? "against" : null;
266const L = args.label ?? (role === "fix" ? "the fix" : "the spec");
267const within = role === "fix" ? "with" : "in";
268const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
269
270const mapping = await import(pathToFileURL(path.resolve(args.map)).href);
271if (typeof mapping.state !== "function")
272 throw new Error("the mapping module must export state(vars)");
273if (args.view !== "customer" && typeof mapping.detail !== "function")
274 throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`);
275const SEP = "\u0000";
276const edgeId = (from, to) => `${from}${SEP}${SEP}${to}`;
277
278const rules = new Map();
279for (const f of args.specs)
280 for (const [name, sentence] of ruleSentences(fs.readFileSync(f, "utf8")))
281 if (!rules.has(name)) rules.set(name, sentence);
282
283const readerActions = new Set(mapping.readerActions ?? []);
284const factsFrom = (enabled) => ({
285 enabled,
286 settled: [...enabled].every((a) => readerActions.has(a)),
287});
288function keyOf(vars, facts) {
289 if (args.view === "developer") return String(mapping.detail(vars, facts));
290 const outer = mapping.state(vars, facts);
291 if (typeof outer !== "string")
292 throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`);
293 return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer;
294}
295function stepLabel(action, before, after, view = args.view) {
296 const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action;
297 const named = view === "customer" || said === action ? said : `${said} [${action}]`;
298 const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null;
299 if (seconds === undefined || seconds === null || seconds === "" || !named) return named;
300 if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)
301 throw new Error(
302 `timer() must return a number of seconds above 0, or nothing; got ${JSON.stringify(seconds)} for ${action}`,
303 );
304 return `after ${seconds} s, ${named}`;
305}
306function stepGuard(action, before, after) {
307 if (typeof mapping.guard !== "function") return "";
308 return String(mapping.guard(action, before, after) ?? "")
309 .replace(/[[\]]/g, "")
310 .trim();
311}
312function guarded(guards, label) {
313 const words = guards?.get(label);
314 return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label;
315}
316const addGuard = (guards, label, words) => {
317 if (!guards.has(label)) guards.set(label, new Set());
318 guards.get(label).add(words);
319};
320
321function lacking(a, b, prefix = []) {
322 return Object.keys(a).flatMap((k) =>
323 !(k in b)
324 ? [[...prefix, k]]
325 : isRecord(a[k]) && isRecord(b[k])
326 ? lacking(a[k], b[k], [...prefix, k])
327 : [],
328 );
329}
330const holder = (o, p) => p.slice(0, -1).reduce((x, k) => (isRecord(x?.[k]) ? x[k] : null), o);
331function omit(vars, paths) {
332 if (!paths.length) return vars;
333 const out = structuredClone(vars);
334 for (const p of paths) {
335 const o = holder(out, p);
336 if (o) delete o[p.at(-1)];
337 }
338 return out;
339}
340function guard(vars, paths, at = "v") {
341 if (!paths.length || !isRecord(vars)) return vars;
342 const missing = new Set();
343 const deeper = new Map();
344 for (const [k, ...rest] of paths)
345 if (!rest.length) missing.add(k);
346 else deeper.set(k, [...(deeper.get(k) ?? []), rest]);
347 return new Proxy(vars, {
348 get(target, k) {
349 if (missing.has(k) && !(k in target)) {
350 const name = `${at}.${k}`.slice(2);
351 throw new Error(
352 `${L} has no ${name}, and the mapping reads ${at}.${k} on its states. Label states and steps only from what both models have, or test "${k}" in ${at} first.`,
353 );
354 }
355 return deeper.has(k) ? guard(target[k], deeper.get(k), `${at}.${k}`) : target[k];
356 },
357 });
358}
359
360function modelOf(graph) {
361 const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()]));
362 for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action);
363 const index = new Map([...graph.nodes.values()].map((n) => [stateKey(n.vars), byId.get(n.id)]));
364 const facts = (vars) => factsFrom(index.get(stateKey(vars)) ?? new Set());
365 return {
366 graph,
367 facts,
368 key: (vars) => keyOf(vars, facts(vars)),
369 customer: (vars) => String(mapping.state(vars, facts(vars))),
370 };
371}
372
373function quotient(m) {
374 const { graph } = m;
375 const keys = new Map();
376 for (const n of graph.nodes.values()) keys.set(n.id, m.key(n.vars));
377 const nodes = new Set(keys.values());
378 const initial = new Set(
379 [...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id)),
380 );
381 const edges = new Map();
382 for (const e of graph.edges) {
383 const from = keys.get(e.from);
384 const to = keys.get(e.to);
385 if (from === to) continue;
386 const id = edgeId(from, to);
387 if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() });
388 const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars];
389 const label = stepLabel(e.action, before, after);
390 if (!label) continue;
391 edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1);
392 addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after));
393 }
394 for (const e of edges.values())
395 e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l));
396 const settled = new Set(
397 [...graph.nodes.values()].filter((n) => m.facts(n.vars).settled).map((n) => keys.get(n.id)),
398 );
399 return { nodes, initial, edges, keys, settled };
400}
401
402const graph = parseDot(fs.readFileSync(args.dot, "utf8"));
403const main = modelOf(graph);
404const now = quotient(main);
405const mainInit = [...graph.nodes.values()].find((n) => n.init).vars;
406
407const secondDot = args.fixedDot ?? args.againstDot;
408const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null;
409const secondInit = secondGraph && [...secondGraph.nodes.values()].find((n) => n.init).vars;
410const lacks = secondGraph ? lacking(mainInit, secondInit) : [];
411const extra = secondGraph ? lacking(secondInit, mainInit) : [];
412const second =
413 secondGraph &&
414 modelOf({
415 ...secondGraph,
416 nodes: new Map(
417 [...secondGraph.nodes].map(([id, n]) => [
418 id,
419 { ...n, raw: n.vars, vars: guard(n.vars, lacks) },
420 ]),
421 ),
422 });
423const secondQ = second && quotient(second);
424
425function readerPath(trace, key, labelOf = stepLabel) {
426 const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }];
427 for (let i = 1; i < trace.steps.length; i++) {
428 const { action } = trace.steps[i];
429 const k = key(trace.steps[i].vars);
430 if (k === out[out.length - 1].key && !readerActions.has(action)) continue;
431 const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars];
432 const label = labelOf(action, before, after) || action;
433 const guard = stepGuard(action, before, after);
434 out.push({ key: k, label, guard, action, modelStep: i });
435 }
436 return out;
437}
438
439const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer");
440function replaySecond(trace) {
441 if (!second) return null;
442 const shared = (vars) => stateKey(omit(vars, lacks));
443 const sharedOf = (n) => stateKey(omit(n.raw, extra));
444 const out = new Map();
445 for (const e of [...second.graph.edges, ...second.graph.loops]) {
446 if (!out.has(e.from)) out.set(e.from, []);
447 out.get(e.from).push(e);
448 }
449 const actions = new Set([...second.graph.edges, ...second.graph.loops].map((e) => e.action));
450 const nodeOf = (id) => second.graph.nodes.get(id);
451 const screen = new Map(
452 [...second.graph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]),
453 );
454 const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action;
455 const settle = (ids) => {
456 const all = new Set(ids);
457 for (const id of all)
458 for (const e of out.get(id) ?? [])
459 if (!readerActions.has(e.action) && screen.get(e.to) === screen.get(id)) all.add(e.to);
460 return all;
461 };
462 const seen = readerPath(trace, main.customer, readerLabel);
463 const shownAt = new Map(seen.map((p, j) => [p.modelStep, j]));
464 let here = settle(
465 [...second.graph.nodes.values()]
466 .filter((n) => n.init && screen.get(n.id) === seen[0].key)
467 .map((n) => n.id),
468 );
469 if (here.size === 0) return { reason: "starts on another screen" };
470 const passed = new Set(here);
471 let j = 0;
472 for (let i = 1; i < trace.steps.length; i++) {
473 const { action } = trace.steps[i];
474 const hidden = !shownAt.has(i);
475 if (!hidden) j = shownAt.get(i);
476 const want = seen[j];
477 const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars];
478 const known = actions.has(action);
479 if (!known && (hidden || shared(before) === shared(after))) continue;
480 const tries = [...here].flatMap((id) =>
481 (out.get(id) ?? []).filter((e) =>
482 known ? e.action === action : sharedOf(nodeOf(e.to)) === shared(after),
483 ),
484 );
485 if (hidden && tries.length === 0) continue;
486 const same = known
487 ? tries.filter((e) => screen.get(e.to) === want.key && (hidden || words(e) === want.label))
488 : tries;
489 if (same.length) {
490 here = settle(same.map((e) => e.to));
491 for (const id of here) passed.add(id);
492 continue;
493 }
494 const outcomes = new Map();
495 for (const e of tries) {
496 const id = `${screen.get(e.to)}${SEP}${words(e)}`;
497 outcomes.set(id, [...(outcomes.get(id) ?? []), e]);
498 }
499 const taken = [...outcomes.values()].sort((a, b) => b.length - a.length)[0]?.[0];
500 const from = main.key(before);
501 const said = hidden ? readerLabel(action, before, after) || action : want.label;
502 const at = { step: j, said, from, hidden };
503 if (!taken)
504 return {
505 ...at,
506 key: from,
507 label: stepLabel(action, before, after) || action,
508 disabled: true,
509 };
510 const [b, a] = [nodeOf(taken.from).vars, nodeOf(taken.to).vars];
511 return { ...at, key: second.key(a), label: stepLabel(taken.action, b, a) || taken.action };
512 }
513 const bad = new Set(
514 rawSecondTraces
515 .filter((t) => t.invariant === trace.invariant)
516 .map((t) => stateKey(t.steps[t.steps.length - 1].vars)),
517 );
518 const checked = secondRules.has(trace.invariant);
519 const ruleHolds = checked && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars)));
520 return { sameEnd: true, checked, ruleHolds };
521}
522
523function readTraces(files, paths = []) {
524 return files.flatMap((f) => {
525 const text = fs.readFileSync(f, "utf8");
526 const warning = tlcOutputWarning(text, f);
527 if (warning) console.error(`${f}: ${warning}`);
528 return parseTraces(text).map((t) => ({
529 ...t,
530 steps: t.steps.map((s) => ({ ...s, vars: guard(s.vars, paths) })),
531 }));
532 });
533}
534function firstBad(raw) {
535 const badStates = new Map();
536 for (const t of raw) {
537 if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set());
538 badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars));
539 }
540 const cuts = [];
541 const seen = new Set();
542 for (const t of raw) {
543 const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars)));
544 const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) };
545 const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`;
546 if (seen.has(id)) continue;
547 seen.add(id);
548 cuts.push(cut);
549 }
550 return cuts;
551}
552const rawTraces = readTraces(args.traces);
553const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces;
554const secondRules = new Set(role === "fix" ? args.fixedRules : args.againstRules);
555const rawSecondTraces = readTraces(secondTraceFiles, lacks);
556const traces = firstBad(rawTraces);
557const secondTraces = firstBad(rawSecondTraces);
558function group(cuts, m) {
559 const byId = new Map();
560 for (const trace of cuts) {
561 const rp = readerPath(trace, m.customer);
562 const last = trace.steps[trace.steps.length - 1].vars;
563 const said =
564 typeof mapping.cause === "function"
565 ? mapping.cause(last, m.facts(last), trace.invariant)
566 : null;
567 const cause = said === undefined || said === null ? null : String(said);
568 const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`;
569 const prev = byId.get(id);
570 if (
571 !prev ||
572 rp.length < prev.steps ||
573 (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length)
574 ) {
575 byId.set(id, {
576 id,
577 cause,
578 steps: rp.length,
579 trace,
580 invariant: trace.invariant,
581 count: (prev?.count ?? 0) + 1,
582 });
583 } else prev.count++;
584 }
585 return byId;
586}
587const byOrder = (a, b) =>
588 a.steps - b.steps ||
589 a.trace.steps.length - b.trace.steps.length ||
590 (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
591const inSecond = (l) => `${within} ${L}, ${l}`;
592const modelEdges = new Map(now.edges);
593if (secondQ)
594 for (const [id, e] of secondQ.edges)
595 if (!modelEdges.has(id))
596 modelEdges.set(id, {
597 ...e,
598 labels: new Set([...e.labels].map(inSecond)),
599 guards: new Map([...e.guards].map(([l, words]) => [inSecond(l), words])),
600 secondOnly: true,
601 });
602function divergence(s) {
603 if (s?.key === undefined) return s;
604 const plain = edgeId(s.from, s.key);
605 const text = inSecond(`${s.label}${s.disabled ? " cannot happen" : ""}`);
606 const drawn = !s.disabled && [s.label, text].some((l) => modelEdges.get(plain)?.labels.has(l));
607 return { ...s, text, edge: drawn ? plain : `${plain}${SEP}second` };
608}
609const withPath = (c, m) => {
610 const path = readerPath(c.trace, m.key);
611 return { ...c, path, end: path[path.length - 1].key };
612};
613const classesById = group(traces, main);
614const mainClasses = [...classesById.values()]
615 .sort(byOrder)
616 .map((c) => ({ ...withPath(c, main), second: divergence(replaySecond(c.trace)) }));
617const introduced = second
618 ? [...group(secondTraces, second).values()]
619 .filter((c) => !classesById.has(c.id))
620 .sort(byOrder)
621 .map((c) => ({ ...withPath(c, second), second: null, introduced: true }))
622 : [];
623const classes = [...mainClasses, ...introduced];
624
625const diverges = (c) => c.second?.key !== undefined;
626function badKeys(m, keys, raw) {
627 const g = m.graph;
628 const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars)));
629 const out = new Set(raw.map((t) => m.key(t.steps[t.steps.length - 1].vars)));
630 const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars));
631 const next = new Map();
632 for (const e of g.edges) {
633 if (!next.has(e.from)) next.set(e.from, []);
634 next.get(e.from).push(e.to);
635 }
636 const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id));
637 const queue = [...clean];
638 while (queue.length) {
639 for (const to of next.get(queue.shift()) ?? []) {
640 if (clean.has(to) || !ok(to)) continue;
641 clean.add(to);
642 queue.push(to);
643 }
644 }
645 const reached = new Set([...clean].map((id) => keys.get(id)));
646 for (const k of keys.values()) if (!reached.has(k)) out.add(k);
647 return out;
648}
649const badNow = badKeys(main, now.keys, rawTraces);
650const uncheckedTraces = rawTraces.filter((t) => !secondRules.has(t.invariant));
651const badSecond = second
652 ? new Set([
653 ...badKeys(second, secondQ.keys, rawSecondTraces),
654 ...(role === "fix" ? badKeys(main, now.keys, uncheckedTraces) : []),
655 ])
656 : new Set();
657const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]);
658const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]);
659const onlySecond = (k) => second && !now.nodes.has(k);
660const status = (k) => {
661 if (role === "against")
662 return badNow.has(k) || badSecond.has(k) ? "wrong" : onlySecond(k) ? "fixed" : "works";
663 if (role === "fix")
664 return badSecond.has(k) ? "wrong" : badNow.has(k) || onlySecond(k) ? "fixed" : "works";
665 return badNow.has(k) ? "wrong" : "works";
666};
667
668const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null;
669if (args.highlight > 0 && classes.length > 0 && !chosen)
670 throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`);
671const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []);
672if (chosen && diverges(chosen)) onPath.add(chosen.second.key);
673
674const edgesAlong = (c) =>
675 c.path.slice(1).map((p, i) => ({
676 id: edgeId(c.path[i].key, p.key),
677 from: c.path[i].key,
678 to: p.key,
679 label: p.label,
680 guard: p.guard,
681 }));
682function chartEdges() {
683 const edges = new Map(modelEdges);
684 for (const { second: s } of classes.filter(diverges)) {
685 if (modelEdges.has(s.edge)) continue;
686 if (!edges.has(s.edge))
687 edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true });
688 edges.get(s.edge).labels.add(s.text);
689 }
690 const pathNumbers = new Map();
691 (chosen ? edgesAlong(chosen) : []).forEach((step, i) => {
692 if (!pathNumbers.has(step.id)) pathNumbers.set(step.id, []);
693 const shown = step.guard ? `${step.label} [${step.guard}]` : step.label;
694 pathNumbers.get(step.id).push({ n: i + 1, label: step.label, shown });
695 if (!edges.has(step.id))
696 edges.set(step.id, { from: step.from, to: step.to, labels: new Set([step.label]) });
697 });
698 if (!args.cover) return { edges, pathNumbers, coverEdges: null };
699
700 const coverEdges = new Set();
701 const bfs = (q, targets) => {
702 const parent = new Map([...q.initial].map((k) => [k, null]));
703 const queue = [...q.initial];
704 const outEdges = new Map();
705 for (const [id, e] of q.edges) {
706 if (!outEdges.has(e.from)) outEdges.set(e.from, []);
707 outEdges.get(e.from).push([id, e]);
708 }
709 while (queue.length) {
710 const k = queue.shift();
711 for (const [id, e] of outEdges.get(k) ?? []) {
712 if (parent.has(e.to)) continue;
713 parent.set(e.to, id);
714 queue.push(e.to);
715 }
716 }
717 for (const t of targets) {
718 for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) coverEdges.add(id);
719 }
720 };
721 bfs(now, now.settled);
722 if (secondQ)
723 bfs(
724 secondQ,
725 [...secondQ.settled].filter((k) => !now.nodes.has(k)),
726 );
727 const pathLabels = new Map();
728 for (const c of classes) {
729 for (const step of edgesAlong(c)) {
730 coverEdges.add(step.id);
731 pathLabels.set(step.id, (pathLabels.get(step.id) ?? new Set()).add(step.label));
732 }
733 if (diverges(c)) coverEdges.add(c.second.edge);
734 }
735 for (const id of coverEdges) {
736 if (edges.has(id)) continue;
737 const [from, to] = id.split(`${SEP}${SEP}`);
738 edges.set(id, { from, to, labels: pathLabels.get(id) });
739 }
740 return { edges, pathNumbers, coverEdges };
741}
742const { edges, pathNumbers, coverEdges } = chartEdges();
743
744const LEGEND = {
745 works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")],
746 wrong: [
747 "legendWrong",
748 mapping.legend?.wrong ?? (role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"),
749 ],
750 fixed: [
751 "legendFixed",
752 mapping.legend?.fixed ?? (role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`),
753 ],
754};
755
756const grouping = typeof mapping.group === "function";
757const groupsOf = new Map();
758const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : "");
759const groupNames = [];
760const fills = new Map();
761if (grouping) {
762 const add = (k, vars, m) => {
763 const g = mapping.group(vars, m.facts(vars));
764 if (g !== undefined && g !== null && typeof g !== "string")
765 throw new Error(`group() must return a group name or nothing; got ${JSON.stringify(g)}`);
766 if (!groupsOf.has(k)) groupsOf.set(k, new Set());
767 groupsOf.get(k).add((g ?? "").replace(/\s+/g, " ").trim());
768 };
769 for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main);
770 if (second)
771 for (const n of second.graph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second);
772
773 const given = mapping.groupColors ?? {};
774 if (!isRecord(given))
775 throw new Error("groupColors must be an object from a group name to a color such as #d6eaf8");
776 const statusNames = new Set(
777 [
778 "Works",
779 "Works today",
780 "Breaks a rule",
781 "Still wrong",
782 "Fixed",
783 ...Object.values(LEGEND).map(([, t]) => t),
784 ].map((t) => t.toLowerCase()),
785 );
786 const found = new Set([...groupsOf.values()].flatMap((gs) => [...gs]).filter(Boolean));
787 for (const g of found)
788 if (statusNames.has(g.toLowerCase()))
789 throw new Error(
790 `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`,
791 );
792 groupNames.push(
793 ...Object.keys(given).filter((g) => found.has(g)),
794 ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(),
795 );
796 for (const [g, color] of Object.entries(given)) {
797 if (color === undefined || color === null || color === "") continue;
798 const why = groupColorProblem(color);
799 if (why) throw new Error(`groupColors["${g}"] ${JSON.stringify(color)} ${why}`);
800 if (found.has(g)) fills.set(g, color.toLowerCase());
801 }
802 const free = GROUP_PALETTE.filter((c) => ![...fills.values()].includes(c));
803 for (const g of groupNames) {
804 if (fills.has(g)) continue;
805 if (!free.length)
806 throw new Error(
807 `the mapping has ${groupNames.length} groups, more than the ${GROUP_PALETTE.length} colors of the palette; give the others a color in groupColors`,
808 );
809 fills.set(g, free.shift());
810 }
811}
812const mixedGroups = [...groupsOf].filter(([, gs]) => gs.size > 1).map(([k]) => k);
813
814const BORDER = { works: "", wrong: "Wrong", fixed: "Fixed" };
815const classFor = (g, s, path) =>
816 grouping
817 ? `${g ? `group${groupNames.indexOf(g)}` : "plain"}${BORDER[s] || (path ? "Path" : "")}`
818 : `${s}${path ? "Path" : ""}`;
819const drawnState = (g, s, path = false) => ({ group: g, status: s, cls: classFor(g, s, path) });
820const groupCounts = (drawn) =>
821 groupNames
822 .map((g) => ({
823 name: g,
824 color: fills.get(g),
825 states: drawn.filter((d) => d.group === g).length,
826 }))
827 .filter((g) => g.states > 0);
828
829const frontMatter = (title) => [
830 "---",
831 ...(title ? [`title: ${JSON.stringify(title)}`] : []),
832 "config:",
833 " markdownAutoWrap: false",
834 " state:",
835 " useMaxWidth: false",
836 "---",
837];
838function head(drawn) {
839 const out = [...frontMatter(args.title), "stateDiagram-v2"];
840 if (role) out.push(` %% second model (${role}): ${L}`);
841 if (args.fullLabels) out.push(` ${FULL_LABELS}`);
842 const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s));
843 const legend = grouping
844 ? [
845 ...groupCounts(drawn).map(({ name }) => {
846 const i = groupNames.indexOf(name);
847 return [`legendGroup${i}`, name, `group${i}`];
848 }),
849 ...used.filter((s) => BORDER[s]).map((s) => [...LEGEND[s], `plain${BORDER[s]}`]),
850 ]
851 : used.map((s) => [...LEGEND[s], s]);
852 if (grouping) {
853 const styleClasses = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]);
854 const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])];
855 for (const [base, fill] of bases)
856 for (const border of ["", "Wrong", "Fixed", "Path"])
857 if (styleClasses.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`);
858 } else for (const c of STATUS_CLASSES) out.push(` ${c}`);
859 if (legend.length) {
860 out.push(` state "Legend" as ${LEGEND_ID} {`);
861 for (const [id, text] of legend) out.push(` state "${escapeMermaidQuotes(text)}" as ${id}`);
862 out.push(" }");
863 for (const [id, , c] of legend) out.push(` class ${id} ${c}`);
864 }
865 return out;
866}
867
868function regionsChart({ states, starts, edges, classes, proofPath, allPath }) {
869 const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim();
870 const split = (k) => {
871 const at = k.indexOf(": ");
872 return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null;
873 };
874 const nodes = [...states].sort();
875 if (!nodes.every(split)) return null;
876 const steps = [];
877 for (const e of edges.values()) {
878 if (e.labels.size === 0) return { why: "a step has no label" };
879 for (const l of e.labels)
880 steps.push({
881 from: split(e.from),
882 to: split(e.to),
883 label: norm(l),
884 words: e.guards?.get(l) ?? new Set([""]),
885 });
886 }
887 const bar = (p) => p.join(" | ");
888 const flat = {
889 Main: {
890 states: new Set(nodes.map((k) => bar(split(k)))),
891 initial: new Set(starts.map((k) => bar(split(k)))),
892 transitions: steps.map((s) => ({ from: bar(s.from), to: bar(s.to), label: s.label })),
893 },
894 };
895 const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`]));
896 const proof = [
897 ...frontMatter(),
898 "stateDiagram-v2",
899 ...[...ids].map(([s, id]) => ` state "${escapeMermaidQuotes(s)}" as ${id}`),
900 ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`),
901 ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`),
902 ];
903 if (proofPath) fs.writeFileSync(proofPath, proof.join("\n") + "\n");
904
905 const beside = [new Map(), new Map()];
906 for (const k of nodes) {
907 const p = split(k);
908 for (const i of [0, 1]) {
909 if (!beside[i].has(p[i])) beside[i].set(p[i], new Set());
910 beside[i].get(p[i]).add(p[1 - i]);
911 }
912 }
913 const size = beside[0].size + beside[1].size;
914 if (size >= nodes.length)
915 return { why: `${size} region states are not fewer than the ${nodes.length} flat states` };
916
917 const rank = ["works", "fixed", "wrong"];
918 const regionStatus = [new Map(), new Map()];
919 for (const k of nodes) {
920 const p = split(k);
921 for (const i of [0, 1]) {
922 const prev = regionStatus[i].get(p[i]);
923 if (!prev || rank.indexOf(status(k)) < rank.indexOf(prev))
924 regionStatus[i].set(p[i], status(k));
925 }
926 }
927 for (const k of nodes) {
928 const p = split(k);
929 const shown = rank[Math.max(...[0, 1].map((i) => rank.indexOf(regionStatus[i].get(p[i]))))];
930 if (shown !== status(k))
931 return {
932 why: `"${k}" is "${LEGEND[status(k)][1]}", and its two region states would show "${LEGEND[shown][1]}"`,
933 };
934 }
935
936 const moves = [new Map(), new Map()];
937 for (const s of steps)
938 for (const i of [0, 1]) {
939 const id = [s.from[i], s.label, s.to[i]].join(SEP);
940 if (!moves[i].has(id))
941 moves[i].set(id, {
942 from: s.from[i],
943 label: s.label,
944 to: s.to[i],
945 beside: new Set(),
946 guards: new Map(),
947 });
948 moves[i].get(id).beside.add(s.from[1 - i]);
949 for (const w of s.words) addGuard(moves[i].get(id).guards, s.label, norm(w));
950 }
951 for (const m of moves)
952 for (const [id, x] of m)
953 if (
954 x.from === x.to &&
955 ![...m.values()].some((y) => y.from === x.from && y.label === x.label && y.to !== x.from)
956 )
957 m.delete(id);
958 const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`])));
959 const regionGroup = [new Map(), new Map()];
960 for (const k of nodes) {
961 const p = split(k);
962 for (const i of [0, 1]) {
963 const prev = regionGroup[i].get(p[i]);
964 regionGroup[i].set(p[i], prev === undefined || prev === groupOf(k) ? groupOf(k) : "");
965 }
966 }
967 const drawn = rid.map((m, i) =>
968 [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))),
969 );
970 const draw = (only) => {
971 const out = head(drawn.flat());
972 out.push(` state "At the same time" as regions {`);
973 let count = 0;
974 for (const i of [0, 1]) {
975 if (i) out.push(" --");
976 const name = mapping.regionNames?.[i];
977 const pad = name ? " " : " ";
978 if (name) out.push(` state "${escapeMermaidQuotes(wrap(name))}" as region${i} {`);
979 for (const s of new Set(starts.map((k) => split(k)[i])))
980 out.push(`${pad}[*] --> ${rid[i].get(s)}`);
981 for (const [s, id] of rid[i])
982 out.push(`${pad}state "${escapeMermaidQuotes(wrap(s))}" as ${id}`);
983 const shownMoves = [...moves[i]]
984 .filter(([id]) => !only || only[i].has(id))
985 .map(([, m]) => m)
986 .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label));
987 for (const m of shownMoves) {
988 const all = beside[i].get(m.from);
989 const not = [...all].filter((s) => !m.beside.has(s)).sort();
990 const yes = [...m.beside].sort();
991 const unless = not.length > 0 && not.length < yes.length;
992 const guard = not.length === 0 ? null : unless ? not : yes;
993 const worded = guarded(m.guards, m.label);
994 const text =
995 worded !== m.label || !guard
996 ? worded
997 : `${m.label} [${unless ? "unless" : "when"} ${guard.join(" or ")}]`;
998 out.push(`${pad}${rid[i].get(m.from)} --> ${rid[i].get(m.to)} : ${wrap(text, 30)}`);
999 if (guard)
1000 out.push(
1001 `${pad}%% when ${unless ? "not " : ""}${guard.map((s) => rid[1 - i].get(s)).join(", ")}`,
1002 );
1003 count++;
1004 }
1005 if (name) out.push(" }");
1006 }
1007 out.push(" }");
1008 for (const i of [0, 1])
1009 [...rid[i].values()].forEach((id, j) => out.push(` class ${id} ${drawn[i][j].cls}`));
1010 return { mermaid: out.join("\n") + "\n", count };
1011 };
1012
1013 const all = draw(null);
1014 const differences = compareCharts(parseMermaid(all.mermaid).regions, flat);
1015 if (differences.length) {
1016 const n = differences.reduce((sum, [, xs]) => sum + xs.length, 0);
1017 return {
1018 why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`,
1019 };
1020 }
1021 if (allPath) fs.writeFileSync(allPath, all.mermaid);
1022
1023 const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)]));
1024 const key = (from, label, to) => `${from} --> ${to} : ${label}`;
1025 const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to))));
1026 const next = new Map();
1027 for (const s of steps) {
1028 if (!next.has(bar(s.from))) next.set(bar(s.from), []);
1029 next.get(bar(s.from)).push(s);
1030 }
1031 const keep = [new Set(), new Set()];
1032 const movesOf = (s) =>
1033 [0, 1]
1034 .filter((i) => s.from[i] !== s.to[i])
1035 .map((i) => [i, [s.from[i], s.label, s.to[i]].join(SEP)]);
1036 const price = (s) => movesOf(s).filter(([i, id]) => !keep[i].has(id)).length * nodes.length + 1;
1037 const wanted = [];
1038 const want = (s) => {
1039 wanted.push(s);
1040 for (const [i, id] of movesOf(s)) keep[i].add(id);
1041 };
1042 const stepsOf = (e, said) => {
1043 const labels = [...e.labels].map(norm);
1044 return (labels.includes(said) ? [said] : labels).map((label) => ({
1045 from: split(e.from),
1046 to: split(e.to),
1047 label,
1048 }));
1049 };
1050 for (const c of classes) {
1051 for (const step of edgesAlong(c)) {
1052 const e = edges.get(step.id);
1053 if (e) stepsOf(e, norm(step.label)).forEach(want);
1054 }
1055 if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want);
1056 }
1057 const cheapest = () => {
1058 const cost = new Map(starts.map((k) => [bar(split(k)), 0]));
1059 const parent = new Map();
1060 const open = new Set(cost.keys());
1061 while (open.size) {
1062 const k = [...open].reduce((a, b) => (cost.get(b) < cost.get(a) ? b : a));
1063 open.delete(k);
1064 for (const s of next.get(k) ?? []) {
1065 const c = cost.get(k) + price(s);
1066 if (c < (cost.get(bar(s.to)) ?? Infinity)) {
1067 cost.set(bar(s.to), c);
1068 parent.set(bar(s.to), s);
1069 open.add(bar(s.to));
1070 }
1071 }
1072 }
1073 return { cost, parent };
1074 };
1075 const targets = [
1076 ...[0, 1].flatMap((i) =>
1077 [...beside[i].keys()].sort().map((r) => ({ at: (k) => parts.get(k)[i] === r })),
1078 ),
1079 ...[...new Set(steps.map((s) => s.label))]
1080 .filter((l) => !wanted.some((s) => s.label === l))
1081 .sort()
1082 .map((l) => ({ ends: (s) => s.label === l })),
1083 ];
1084 while (targets.length) {
1085 const { cost, parent } = cheapest();
1086 let best = null;
1087 targets.forEach((t, n) => {
1088 const ways = t.at
1089 ? [...cost.keys()].filter(t.at).map((k) => ({ k, c: cost.get(k), last: null }))
1090 : steps
1091 .filter(t.ends)
1092 .filter((s) => cost.has(bar(s.from)))
1093 .map((s) => ({ k: bar(s.from), c: cost.get(bar(s.from)) + price(s), last: s }));
1094 for (const w of ways) if (!best || w.c < best.c) best = { ...w, n };
1095 });
1096 if (!best) break;
1097 targets.splice(best.n, 1);
1098 const path = best.last ? [best.last] : [];
1099 for (let s = parent.get(best.k); s; s = parent.get(bar(s.from))) path.push(s);
1100 path.forEach(want);
1101 }
1102 const takes = (x, label) =>
1103 [0, 1].flatMap((i) =>
1104 [...moves[i]]
1105 .filter(([, m]) => m.from === x[i] && m.label === label && m.beside.has(x[1 - i]))
1106 .map(([id]) => [i, id]),
1107 );
1108 let scoped;
1109 let made;
1110 for (;;) {
1111 scoped = draw(keep);
1112 made = productOf(parseMermaid(scoped.mermaid).regions);
1113 const madeKeys = new Set(made.transitions.map((t) => key(t.from, t.label, t.to)));
1114 const redo = [
1115 ...made.transitions
1116 .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to)))
1117 .map((t) => [parts.get(t.from), t.label]),
1118 ...wanted
1119 .filter((s) => made.states.has(bar(s.from)))
1120 .filter((s) => !madeKeys.has(key(bar(s.from), s.label, bar(s.to))))
1121 .map((s) => [s.from, s.label]),
1122 ];
1123 const before = keep[0].size + keep[1].size;
1124 for (const [x, label] of redo) for (const [i, id] of takes(x, label)) keep[i].add(id);
1125 if (keep[0].size + keep[1].size === before) break;
1126 }
1127 const sound =
1128 [...made.states].every((s) => parts.has(s)) &&
1129 made.transitions.every((t) => real.has(key(t.from, t.label, t.to)));
1130 if (!sound) scoped = all;
1131
1132 return {
1133 mermaid: scoped.mermaid,
1134 states: [beside[0].size, beside[1].size],
1135 transitions: scoped.count,
1136 all: all.count,
1137 flat: { states: nodes.length, transitions: steps.length },
1138 status: Object.fromEntries(
1139 rank.map((s) => [
1140 s,
1141 regionStatus.reduce((n, m) => n + [...m.values()].filter((x) => x === s).length, 0),
1142 ]),
1143 ),
1144 groups: groupCounts(drawn.flat()),
1145 };
1146}
1147const regions = args.regions
1148 ? regionsChart({
1149 states: allNodes,
1150 starts: [...initial],
1151 edges,
1152 classes,
1153 proofPath: args.proof,
1154 allPath: args.regionsAll,
1155 })
1156 : null;
1157if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`);
1158
1159const flatStates =
1160 args.pathOnly && chosen
1161 ? onPath
1162 : coverEdges
1163 ? new Set([
1164 ...initial,
1165 ...[...coverEdges].flatMap((id) => [edges.get(id).from, edges.get(id).to]),
1166 ])
1167 : allNodes;
1168const sorted = [...flatStates].sort();
1169const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k)));
1170
1171const boxes = new Map();
1172const boxOf = new Map();
1173const inBox = (k, box) => {
1174 if (!boxes.has(box)) boxes.set(box, []);
1175 boxes.get(box).push(k);
1176};
1177if (args.view === "layered") for (const k of sorted) inBox(k, k.split(SEP)[0]);
1178else if (args.view === "customer" && !args.flat) {
1179 for (const k of sorted) {
1180 const at = k.indexOf(": ");
1181 if (at > 0 && at + 2 < k.length) inBox(k, k.slice(0, at));
1182 }
1183 for (const [box, members] of boxes) if (members.length < 2) boxes.delete(box);
1184}
1185const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`]));
1186for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box);
1187const declared = [...[...boxes.values()].flat(), ...sorted.filter((k) => !boxOf.has(k))];
1188const stateIds = new Map(declared.map((k, i) => [k, `s${i}`]));
1189const idOf = (k) => stateIds.get(k);
1190const caption = (k) =>
1191 args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k;
1192
1193function numbered(steps) {
1194 const runs = [];
1195 for (const { n, shown } of steps) {
1196 const last = runs.at(-1);
1197 if (last?.shown === shown && last.n.at(-1) === n - 1) last.n.push(n);
1198 else runs.push({ shown, n: [n] });
1199 }
1200 return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`);
1201}
1202const drawnLabels = new Set();
1203const arrows = new Map();
1204for (const [id, e] of edges) {
1205 if (!flatStates.has(e.from) || !flatStates.has(e.to)) continue;
1206 if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue;
1207 if (coverEdges && !coverEdges.has(id)) continue;
1208 const pair = edgeId(e.from, e.to);
1209 if (!arrows.has(pair))
1210 arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] });
1211 const a = arrows.get(pair);
1212 const [numbers, current, other] = a.groups;
1213 const all = [...e.labels];
1214 const shown = all.map((l) => guarded(e.guards, l));
1215 const said = args.fullLabels ? shown.join(" or ") : joinLabels(shown);
1216 const p = pathNumbers.get(id);
1217 const chosenStep = chosen?.second && id === chosen.second.edge;
1218 if (p) numbers.push(...numbered(p));
1219 else if (!e.secondOnly) current.push(said);
1220 else other.push(args.pathOnly && chosenStep ? chosen.second.text : said);
1221 if (chosenStep && !e.secondOnly) other.push(chosen.second.text);
1222 if (p || chosenStep || id.endsWith(`${SEP}second`)) a.plain = false;
1223 a.labels.push(...all);
1224 for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l);
1225}
1226const transitions = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({
1227 from,
1228 to,
1229 // ";" ends a Mermaid statement, even inside a transition label.
1230 text: groups
1231 .map((g) =>
1232 g
1233 .filter(Boolean)
1234 .map((l) => wrap(l.replaceAll(";", ","), 30))
1235 .join("<br/>"),
1236 )
1237 .filter(Boolean)
1238 .join("<br/>#160;<br/>"),
1239 plain,
1240 labels: labels.sort(),
1241}));
1242const fromBox = [];
1243const folded = new Set();
1244for (const [box, members] of boxes) {
1245 const mine = new Set(members);
1246 const byStep = new Map();
1247 for (const l of transitions) {
1248 if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue;
1249 const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`;
1250 if (!byStep.has(step)) byStep.set(step, []);
1251 byStep.get(step).push(l);
1252 }
1253 for (const same of byStep.values()) {
1254 if (new Set(same.map((l) => l.from)).size !== members.length) continue;
1255 for (const l of same) folded.add(l);
1256 fromBox.push({ ...same[0], box });
1257 }
1258}
1259
1260const out = head(drawn);
1261for (const [box, members] of boxes) {
1262 out.push(` state "${escapeMermaidQuotes(wrap(box))}" as ${boxIds.get(box)} {`);
1263 for (const k of members)
1264 out.push(` state "${escapeMermaidQuotes(wrap(caption(k)))}" as ${idOf(k)}`);
1265 out.push(" }");
1266}
1267for (const k of sorted)
1268 if (!boxOf.has(k)) out.push(` state "${escapeMermaidQuotes(wrap(caption(k)))}" as ${idOf(k)}`);
1269for (const k of sorted)
1270 if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key))
1271 out.push(` [*] --> ${idOf(k)}`);
1272const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`;
1273for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l));
1274for (const l of transitions) if (!folded.has(l)) out.push(arrow(idOf(l.from), l));
1275sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`));
1276const mermaid = regions?.mermaid ?? out.join("\n") + "\n";
1277if (args.out) fs.writeFileSync(args.out, mermaid);
1278else process.stdout.write(mermaid);
1279
1280const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k);
1281const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``;
1282function secondLine(c) {
1283 const W = cap(within);
1284 if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`;
1285 const s = c.second;
1286 if (!s) return null;
1287 if (s.sameEnd)
1288 return !s.checked
1289 ? `${W} ${L} you see the same screens and steps, but ${L} does not check this rule.`
1290 : s.ruleHolds
1291 ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.`
1292 : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`;
1293 if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`;
1294 const outcome = s.disabled
1295 ? "cannot happen, so you stay at"
1296 : s.key === s.from
1297 ? "keeps you at"
1298 : "takes you to";
1299 if (s.hidden) {
1300 const when = s.step ? `after step ${s.step}` : "before your first step";
1301 return `${W} ${L}, ${when}, "${s.said}" happens and ${outcome}: ${show(s.key)}.`;
1302 }
1303 return `${W} ${L}, step ${s.step} (${s.said}) ${outcome}: ${show(s.key)}.`;
1304}
1305const listedIn = (c) =>
1306 Object.keys(args.listed.breaks).filter((cfg) => args.listed.breaks[cfg] === c.invariant);
1307function listedLine(c) {
1308 const { spec, breaks } = args.listed;
1309 const cfgs = listedIn(c);
1310 if (cfgs.length)
1311 return `Listed: ${joinAnd(cfgs)}, ${cfgs.length === 1 ? "a config" : "configs"} of ${spec}, expect${cfgs.length === 1 ? "s" : ""} TLC to break this rule.`;
1312 const others = [...new Set(Object.values(breaks))].sort().map((r) => `\`${r}\``);
1313 return `Not listed: no config of ${spec} expects TLC to break this rule. ${others.length ? `Its configs expect TLC to break ${joinAnd(others)}.` : "None of its configs expects TLC to break a rule."}`;
1314}
1315if (args.story) {
1316 const lines = [];
1317 const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `;
1318 classes.forEach((c, i) => {
1319 lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, "");
1320 lines.push(`Rule broken: ${ruleOf(c)}`);
1321 if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`);
1322 if (args.listed) lines.push(listedLine(c));
1323 lines.push(
1324 c.count === 1
1325 ? "This is the only way to get here in the model."
1326 : `There are ${c.count} ways to get here in the model. This is the shortest.`,
1327 "",
1328 );
1329 c.path.forEach((p, j) =>
1330 lines.push(
1331 j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${cap(p.label)}. Now: ${show(p.key)}`,
1332 ),
1333 );
1334 lines.push("");
1335 const said = secondLine(c);
1336 if (said) lines.push(said, "");
1337 });
1338 fs.writeFileSync(args.story, lines.join("\n"));
1339}
1340const 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]/;
1341const codeLike = [
1342 ...new Set([
1343 ...(args.view === "customer" ? [...sorted, ...drawnLabels] : []),
1344 ...classes.map((c) => c.cause).filter(Boolean),
1345 ]),
1346].filter((t) => CODE_LIKE.test(t));
1347const secondOf = (c) =>
1348 c.introduced
1349 ? role === "fix"
1350 ? `new with ${L}`
1351 : `only in ${L}`
1352 : c.second
1353 ? c.second.sameEnd
1354 ? !c.second.checked
1355 ? "same screens, rule not checked"
1356 : c.second.ruleHolds
1357 ? "same screens, but the rule holds"
1358 : "unchanged"
1359 : (c.second.reason ?? show(c.second.key))
1360 : null;
1361const summary = {
1362 view: args.view,
1363 ...(role
1364 ? {
1365 second: {
1366 role,
1367 label: L,
1368 lacks: lacks.map((p) => p.join(".")),
1369 ...(args.listed ? { listed: args.listed } : {}),
1370 },
1371 }
1372 : {}),
1373 modelStates: graph.nodes.size,
1374 modelTransitions: graph.edges.length,
1375 chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size,
1376 chartTransitions: regions?.mermaid ? regions.transitions : transitions.length,
1377 chartStatus: regions?.mermaid
1378 ? regions.status
1379 : Object.fromEntries(
1380 ["works", "wrong", "fixed"].map((s) => [
1381 s,
1382 [...flatStates].filter((k) => status(k) === s).length,
1383 ]),
1384 ),
1385 ...(grouping
1386 ? {
1387 chartGroups: regions?.mermaid ? regions.groups : groupCounts(drawn),
1388 mixedGroups: mixedGroups.map(show).sort(),
1389 }
1390 : {}),
1391 ...(regions
1392 ? {
1393 regions: regions.mermaid
1394 ? {
1395 drawn: true,
1396 states: regions.states,
1397 transitions: regions.transitions,
1398 all: regions.all,
1399 flat: regions.flat,
1400 }
1401 : { drawn: false, why: regions.why },
1402 }
1403 : {}),
1404 classes: classes.map((c, i) => ({
1405 n: i + 1,
1406 end: show(c.end),
1407 invariant: c.invariant,
1408 rule: rules.get(c.invariant) ?? null,
1409 cause: c.cause,
1410 readerSteps: c.path.length - 1,
1411 modelSteps: c.trace.steps.length,
1412 counterexamples: c.count,
1413 [role ?? "fix"]: secondOf(c),
1414 ...(args.listed ? { listed: listedIn(c) } : {}),
1415 })),
1416 codeLike,
1417};
1418if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n");
1419const extraBugs = introduced.length
1420 ? `, ${introduced.length} ${role === "fix" ? `new with ${L}` : `only in ${L}`}`
1421 : "";
1422const drawnAs = regions?.mermaid
1423 ? ` of ${regions.all} in 2 regions (proved against ${regions.flat.states} states, ${regions.flat.transitions} transitions)`
1424 : "";
1425console.error(
1426 `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` +
1427 `${mainClasses.length} bug(s)${extraBugs}`,
1428);