From 40794876e9fc63aaea3b4d526134547cad5be4b9 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:17:21 +0800 Subject: [PATCH 1/6] chore(upstream): sync latest pstack and harness versions --- README.md | 16 +- docs/guide/01-setup.md | 8 +- docs/guide/02-meta-mode.md | 12 +- docs/guide/03-understand.md | 6 +- docs/guide/04-design.md | 2 +- docs/guide/05-build-and-clean.md | 4 +- docs/guide/06-verify-and-ship.md | 50 +- docs/guide/07-overnight.md | 4 +- docs/guide/08-principles.md | 6 +- docs/guide/09-make-it-yours.md | 8 +- docs/guide/10-recipes-and-pitfalls.md | 30 +- docs/guide/README.md | 2 +- docs/harness-adapters.md | 8 +- docs/t3code.md | 6 +- profiles/skill-manifest.json | 241 +- profiles/skills.json | 5 + profiles/upstream-manifest.json | 157 +- profiles/upstreams.json | 2 +- .../create-formal-verification-skill/SKILL.md | 40 + skills/in-your-own-words/SKILL.md | 6 + .../SKILL.md | 57 + skills/sharpen/SKILL.md | 15 + skills/statechart/SKILL.md | 35 + skills/statechart/scripts/chart-to-tla.mjs | 236 ++ skills/statechart/scripts/compare-charts.mjs | 104 + skills/statechart/scripts/lib.mjs | 723 +++++ .../statechart/scripts/model/OutputFolder.cfg | 6 + .../scripts/model/OutputFolder.map.mjs | 106 + .../statechart/scripts/model/OutputFolder.tla | 176 ++ skills/statechart/scripts/statechart.mjs | 749 +++++ .../scripts/test/statechart.test.mjs | 2790 +++++++++++++++++ skills/statechart/scripts/test/sync.mmd | 19 + skills/statechart/scripts/test/upload.mmd | 16 + skills/statechart/scripts/tlc-to-chart.mjs | 1347 ++++++++ tools/meta-mode/check-plan.test.ts | 96 + 35 files changed, 6908 insertions(+), 180 deletions(-) create mode 100644 skills/create-formal-verification-skill/SKILL.md create mode 100644 skills/in-your-own-words/SKILL.md create mode 100644 skills/maintain-formal-verification-skill/SKILL.md create mode 100644 skills/sharpen/SKILL.md create mode 100644 skills/statechart/SKILL.md create mode 100644 skills/statechart/scripts/chart-to-tla.mjs create mode 100644 skills/statechart/scripts/compare-charts.mjs create mode 100644 skills/statechart/scripts/lib.mjs create mode 100644 skills/statechart/scripts/model/OutputFolder.cfg create mode 100644 skills/statechart/scripts/model/OutputFolder.map.mjs create mode 100644 skills/statechart/scripts/model/OutputFolder.tla create mode 100644 skills/statechart/scripts/statechart.mjs create mode 100644 skills/statechart/scripts/test/statechart.test.mjs create mode 100644 skills/statechart/scripts/test/sync.mmd create mode 100644 skills/statechart/scripts/test/upload.mmd create mode 100644 skills/statechart/scripts/tlc-to-chart.mjs create mode 100644 tools/meta-mode/check-plan.test.ts diff --git a/README.md b/README.md index 796d3c0..f6b4cd0 100644 --- a/README.md +++ b/README.md @@ -441,15 +441,15 @@ cover MCP tools, so Claude Code also gets `--strict-mcp-config` and pi gets shell commands, and mstack has not checked whether it limits configured MCP tools; run Codex with a profile that has no MCP servers when that matters. OpenCode selects its built-in `plan` agent, which denies direct edits but still -allows shell commands in OpenCode 1.18; use a disposable checkout when its +allows shell commands in OpenCode 1.19; use a disposable checkout when its prompt-only write boundary is insufficient. Antigravity's `--mode plan` has the same limit: it is an agent mode, not a tool allowlist. In Antigravity 1.2.17 it left the workspace untouched but still wrote its own plan file under `~/.gemini/antigravity-cli/`. A live check also needs that Harness's credentials and configured model access. -The read-only live prompt last passed on 2026-10-10 with Claude Code 2.1.296, -Codex CLI 0.162.0, OpenCode 1.18.34, and pi 1.1.0 on Windows. +The read-only live prompt was checked on 2026-10-22 with Claude Code 2.1.306, +Codex CLI 0.163.0, OpenCode 1.19.3, and pi 1.3.0 on Windows. The Claude skill-invocation path was verified with Claude Code 2.1.267, the Kiro-Pro configuration, and `claude-haiku-4-5-20251001`. The check required Claude @@ -515,9 +515,9 @@ target directory and restores backups if a commit fails. ## Included skills -The current bundle contains 54 skills. It includes the 50 entries in the -portable pstack tree, three skills adapted from `mattpocock/skills`, and the -local `mstack-help` skill: +The current bundle contains 60 skills. It includes the 56 shipped entries in +the portable pstack tree, three skills adapted from `mattpocock/skills`, and +the local `mstack-help` skill: - Engineering principles for boundaries, domain modelling, idempotence, verification, sequencing, and type safety. @@ -525,6 +525,10 @@ local `mstack-help` skill: instruction writing. - `create-verification-skill` for generating a project-specific verification workflow. +- `create-formal-verification-skill` and `maintain-formal-verification-skill` + for TLA+ based formal verification workflows. +- `statechart` for turning statecharts and TLA+ models into visual charts. +- `in-your-own-words` and `sharpen` for goal restatement and prompt refinement. - `meta-mode` for routing a multi-step task through the capabilities available in the current harness. - `benchmark-checklist` and `correct` for vetting a measured number and for diff --git a/docs/guide/01-setup.md b/docs/guide/01-setup.md index bdba7d6..b66ea8d 100644 --- a/docs/guide/01-setup.md +++ b/docs/guide/01-setup.md @@ -13,6 +13,8 @@ npx @3metajun/mstack --harness The installer reports the target directory and the number of skills it staged. Run it again for another Harness when you use more than one. +In Claude Code, run `/plugin marketplace add cursor/plugins`, then `/plugin install mstack@cursor-plugins`. Claude Code prefixes plugin commands, so each command in this guide takes a `mstack:` prefix there, such as `/mstack:setup-mstack`, which writes its model rule to `~/.claude/rules/mstack-models.md`. The [Claude Code adapter](../harness/claude-code.md) covers the other differences. + ## Pick your models Run: @@ -29,7 +31,7 @@ You only override what you care about. A role with no line in the rule keeps the Use `inherit-parent` for the current chat model through the Harness's documented native inheritance. A new CLI process needs `run-role --parent-model ` to use that same model. `auto` requests the Harness's default selection, which may differ from the chat model. Neither value is a model name. The `reviewer` role accepts a model string or a non-empty list of unique model strings. `/interrogate` runs one reviewer per list entry. CLI fanout uses `--all-models --read-only`; select just one entry with `--model-index 0`. The `implementer` role sets the default model for `/swarm` workers unless a race names a model for each arm. -## Accept the verification offer, or don't +## Accept the verification offers, or don't At the end of setup, `/setup-mstack` looks for a way to prove app behavior in your project, either a `verify-*` skill or an existing harness. If it finds neither, it offers once to generate one with [`/create-verification-skill`](../../skills/create-verification-skill/SKILL.md). @@ -37,6 +39,8 @@ Say yes and it writes `verify-/` under the active Harness's project skill r If you're new to mstack, say yes. An agent that can check its own work keeps going until the check passes. An agent that can't hands every result back to you to check by hand. Of everything in this guide, the verification skill pays off the most. +Then setup offers once to generate a formal verification skill with [`/create-formal-verification-skill`](../../skills/create-formal-verification-skill/SKILL.md), unless the project already has one. Tests check the cases someone thought to write. A formal verification skill checks one key property of your code, such as "a job never runs twice at once", in every case within a stated scope. Say yes when your code has concurrency, retries, a state machine, or a parser, where a bug is costly and tests rarely reach the case that breaks. Otherwise say no, and run it later if that changes. [Verify and ship](./06-verify-and-ship.md#prove-a-property-with-a-formal-verification-skill) covers it. + After setup, start a new session. The model configuration applies to new sessions. ## Keep the cost in check @@ -58,6 +62,6 @@ Pick something real but small, and describe it the way you'd describe it to a co Watch the todo list. Its first items are the matched playbook's steps copied in, the Feature playbook for this prompt. If `/meta-mode` skips a step, the step stays in the list with `skip: `, so you can see what it chose not to do. -From here you can type normal follow-ups. To keep `/meta-mode` on for the whole chat, pick it from the `/` menu with Option+Enter (Mac) or Alt+Enter (Windows) instead of Enter. That makes it a [Custom Mode](https://cursor.com/docs/skills), which stays in context on every turn until you exit it. Custom Modes are available in the Agents Window and the CLI. Plain Enter attaches the skill to one message, and it fades as the chat moves on. +From here you can type normal follow-ups. To keep `/meta-mode` on for the whole chat, pick it from the `/` menu with Option+Enter (Mac) or Alt+Enter (Windows) instead of Enter. That makes it a [Custom Mode](https://cursor.com/docs/skills#using-a-skill-as-a-custom-mode), which stays in context on every turn until you exit it. Custom Modes are available in the Agents Window and the CLI. Plain Enter attaches the skill to one message, and it fades as the chat moves on. Next: [Route work through `/meta-mode`](./02-meta-mode.md). diff --git a/docs/guide/02-meta-mode.md b/docs/guide/02-meta-mode.md index 13f3838..f387bf2 100644 --- a/docs/guide/02-meta-mode.md +++ b/docs/guide/02-meta-mode.md @@ -25,7 +25,7 @@ flowchart TD J --> K ``` -The diagram shows the common routes. There are also playbooks for hillclimbing a metric, diagnosing runtime symptoms and captured traces, prototypes, visual parity, authoring and evaluating skills, autonomous runs, babysitting a PR or stack to merge-ready, shipping a verified stack, running a PR queue on autopilot, orchestrating project-scale programs, session pickup, pausing safely, multi-phase plans, and worktree cleanup. The [playbook directory](../../skills/meta-mode/playbooks/) has the full set. +The diagram shows the common routes. There are also playbooks for hillclimbing a metric, diagnosing runtime symptoms and captured traces, prototypes, visual parity, authoring and evaluating skills, autonomous runs, opening a PR, babysitting a PR or stack to merge-ready, shipping a verified stack, running a PR queue or a stack on autopilot, orchestrating project-scale programs, session pickup, pausing safely, multi-phase plans, and worktree cleanup. The [playbook directory](../../skills/meta-mode/playbooks/) has the full set. ## Say the goal, not the ceremony @@ -64,7 +64,15 @@ For a noisy report, such as a long thread or a vague bug, make the restatement t /meta-mode read this thread. restate the underlying issue in your own words, in plain english. don't change any code yet. ``` -A misreading shows up in the restatement, before any code exists. Correct it there, and it costs you one message instead of one wrong fix. +A 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. + +When you're unsure whether a prompt is aimed right, run [`/sharpen`](../../skills/sharpen/SKILL.md) on it before you send it: + +```text +/sharpen write a migration guide for the v2 config format. +``` + +The 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 `/meta-mode`. ## Follow up short diff --git a/docs/guide/03-understand.md b/docs/guide/03-understand.md index 02cb390..64900a7 100644 --- a/docs/guide/03-understand.md +++ b/docs/guide/03-understand.md @@ -30,9 +30,9 @@ Ask the question you actually have. [`/how`](../../skills/how/SKILL.md) reads th /why was the retry limit set to five? does the reason still hold? ``` -[`/why`](../../skills/why/SKILL.md) works like a detective on a cold case. It starts from source control, then queries whatever evidence categories your MCPs expose, such as the issue tracker, long-form docs, team chat, observability, error tracking, and analytics, all in parallel. The report cites everything, separates direct evidence from inference, and says "appears to" when the record is thin. A null result gets reported too, because "nobody wrote down why" is itself an answer. +[`/why`](../../skills/why/SKILL.md) starts from source control, then queries whatever evidence categories your MCPs expose, such as the issue tracker, long-form docs, team chat, observability, error tracking, and analytics, all in parallel. The report cites everything, separates direct evidence from inference, and says "appears to" when the record is thin. A null result gets reported too, because "nobody wrote down why" is itself an answer. -The two compose naturally. `do why first then how` is a perfectly good prompt when you suspect the history explains the mess. +The two compose naturally. `do why first then how` is a good prompt when you suspect the history explains the mess. ## Actually understand it with `/teach` @@ -40,7 +40,7 @@ The two compose naturally. `do why first then how` is a perfectly good prompt wh /teach me how this PR changes retries. convince me it fixes the cause and not the symptom. ``` -[`/teach`](../../skills/teach/SKILL.md) is for when a summary isn't enough. It runs `/how` and `/why`, for a small change maybe just one of them, and weaves the findings into a plain explanation that builds up diagram by diagram. The "convince me" framing is worth stealing. It turns the explanation into an argument you can poke at instead of a tour. +[`/teach`](../../skills/teach/SKILL.md) is for when a summary isn't enough. It runs `/how` and `/why`, for a small change maybe just one of them, and weaves the findings into a plain explanation that builds up diagram by diagram. The "convince me" framing is worth reusing. It turns the explanation into an argument you can test instead of a tour. It works on the agent's own choices too: diff --git a/docs/guide/04-design.md b/docs/guide/04-design.md index 7ad340a..8177b9c 100644 --- a/docs/guide/04-design.md +++ b/docs/guide/04-design.md @@ -2,7 +2,7 @@ One attempt at a hard design locks in the first shape the model thought of. `/architect` settles types and boundaries before implementation. `/arena` runs several attempts at the same brief and merges the best parts. `/interrogate` has other models try to break the result. When the job is coverage rather than design synthesis, `/swarm` fans out slices or races and aggregates their results. -The two most common design mistakes are taking the agent's first design and polishing a plan that no code has tested. This page fixes both. You plan through code: prototypes answer the open questions, a README or tutorial sets the target, and the written plan comes last. +The two most common design mistakes are taking the agent's first design and polishing a plan that no code has tested. This page fixes both. You plan through code. Prototypes answer the open questions, a README or tutorial sets the target, and the written plan comes last. ![Three robots draft competing bridge models at their own tables under /architect, /arena, and /interrogate panels, while a judge robot with a clipboard inspects skeptically.](./images/design.jpg) diff --git a/docs/guide/05-build-and-clean.md b/docs/guide/05-build-and-clean.md index 05551e2..54f8798 100644 --- a/docs/guide/05-build-and-clean.md +++ b/docs/guide/05-build-and-clean.md @@ -1,6 +1,6 @@ # Build the change and clean the diff -The build playbooks share one discipline. Say what you observed, let the playbook demand the evidence. This page shows what to put in the prompt for each common build task, then the cleanup habit that keeps diffs reviewable. +The build playbooks share one discipline. Say what you observed, and let the playbook demand the evidence. This page shows what to put in the prompt for each common build task, then the cleanup habit that keeps diffs reviewable. ## Prompt each build playbook with what you know @@ -22,7 +22,7 @@ A refactoring prompt pins behavior before structure moves: /meta-mode move parsing into one module, zero behavior change. record the current output first and prove it's unchanged after. ``` -A perf prompt states the measurement, not a vibe: +A perf prompt states the measurement, not an impression: ```text /meta-mode startup takes 1.8s on this fixture. trace it, fix the measured cause, show me before and after. diff --git a/docs/guide/06-verify-and-ship.md b/docs/guide/06-verify-and-ship.md index 8c2c9be..3292e53 100644 --- a/docs/guide/06-verify-and-ship.md +++ b/docs/guide/06-verify-and-ship.md @@ -1,6 +1,6 @@ # Verify the result and open a PR -"It compiles" is not evidence. The [Prove It Works principle](../../skills/principle-prove-it-works/SKILL.md) makes the agent check the real artifact before it reports success, and your job is to make "the real artifact" checkable. This page covers stating a finish condition, vetting a measured number, generating a verification skill for your app, opening the PR, and driving it to merged. +"It compiles" is not evidence. The [Prove It Works principle](../../skills/principle-prove-it-works/SKILL.md) makes the agent check the real artifact before it reports success, and your job is to make "the real artifact" checkable. This page covers stating a finish condition, vetting a measured number, generating a verification skill for your app, formally verifying a property tests rarely reach, opening the PR, and driving it to merged. Verification is the slowest step in most agent work, because it's the step that usually waits on a human. Make the agent able to do it, and you stop being the bottleneck. Skip it, and running more agents only gets you more unchecked work to review. @@ -14,7 +14,7 @@ Put what done means in the first prompt, in whatever words fit: /meta-mode add json output to this command. text output stays byte-identical, the json parses, both run against the sample project. show me the evidence. ``` -Now the agent has three checks it can run, not a mood to satisfy. When the reply comes back, it should carry the exact commands and outputs. If a check couldn't run, a good reply says "inconclusive", and you should treat a confident reply without evidence as a red flag. +Now the agent has three checks it can run, not a mood to satisfy. When the reply comes back, it should carry the exact commands and outputs. If a check couldn't run, a good reply says "inconclusive". A confident reply without evidence is a warning sign. Match the check to the change: @@ -26,7 +26,7 @@ Match the check to the change: Ask for the proof as an artifact you can inspect yourself: the failing test and then the passing one, a before-and-after video, the trace, the screenshot. If the fix already merged, ask for the same check again on main. An artifact beats a plausible explanation, because you can challenge it without replaying the whole run. -For a small diff you don't fully trust, [`/blast-radius`](../../skills/blast-radius/SKILL.md) finds what it could break elsewhere. It picks the one fact the change is safe because of and proves it by running code instead of writing an essay about it. +For a small diff you don't fully trust, [`/blast-radius`](../../skills/blast-radius/SKILL.md) finds what it could break elsewhere. It finds the one fact that makes the change safe and proves that fact by running code, not by arguing for it. ## Vet a measured number with `/benchmark-checklist` @@ -58,7 +58,7 @@ The UI bullet above hides a real requirement. The agent needs a scripted way to [`/create-verification-skill`](../../skills/create-verification-skill/SKILL.md) interviews the repository, not you. It works out what a user touches, how the app launches locally, what can drive it (an existing harness first, otherwise browser and CDP, a PTY, or plain HTTP), what evidence proves behavior, and whether two instances can run side by side. It asks you only what the code can't answer. -It writes `verify-/` under the active Harness's project skill root. See [the project skill paths](../../skills/create-verification-skill/references/harness-paths.md) for the root used by each Harness. The generated skill contains exact Launch, Doctor, Drive, Evidence, and Cleanup sections, plus a feature map under `features/` that indexes what the app does and what result proves each feature works. The skill ships a [worked feature-map example](../../skills/create-verification-skill/references/feature-map-example/) with a README index and one file per feature using the four required H2s. Before handing it over, the generator proves the skill once end to end: launch, doctor check, drive one feature, capture evidence, and clean up. If that proof fails, don't use the output. +It writes `.cursor/skills/verify-/`, agent-facing instructions with exact Launch, Doctor, Drive, Evidence, and Cleanup sections, a Helpers section for any script it ships, plus a feature map under `features/` that indexes what the app does and what result proves each feature works. The skill ships a [worked feature-map example](../../skills/create-verification-skill/references/feature-map-example/) with a README index and one file per feature using the four required H2s. Before handing it over, the generator proves the skill once end to end: launch, doctor check, drive one feature, capture evidence, clean up, then confirm the evidence survived the cleanup. If that proof fails, don't use the output. From then on, "verify it in the app" is a step any agent can execute, in this repo, with no setup conversation. Name it in the prompt when you want the proof in a specific form: @@ -85,13 +85,49 @@ While you're there, make the dev setup repeatable too: seeded data, test users, ## Keep the verification skill honest -Apps change and feature maps rot. Run this at least once a day, ideally from a scheduled automation so nobody has to remember: +Apps change, and feature maps go out of date. Run this at least once a day, ideally from a scheduled automation so nobody has to remember: ```text /maintain-verification-skill ``` -[`/maintain-verification-skill`](../../skills/maintain-verification-skill/SKILL.md) audits the generated skill: one read-only source reader per feature in parallel, then one live pass that drives every mapped feature. It ends in exactly one of three outcomes. `clean` means full coverage and nothing to ship. `changed` means one PR of proven corrections, confined to the verification skill's own directory. `blocked` names the blocker. It never edits product code. If the live pass catches a product regression, it reports the regression instead of papering over it in docs. +[`/maintain-verification-skill`](../../skills/maintain-verification-skill/SKILL.md) audits the generated skill: one read-only source reader per feature in parallel, then one live pass that drives every mapped feature. It ends in exactly one of three outcomes. `clean` means full coverage and nothing to ship. `changed` means one PR of proven corrections, confined to the verification skill's own directory. `blocked` names the blocker. It never edits product code. If the live pass catches a product regression, it reports the regression instead of hiding it with a doc edit. + +## Prove a property with a formal verification skill + +A verification skill proves the paths you drive. Some bugs live on paths nobody drives: two retries that overlap, a lock that expires mid-write, a state change that arrives out of order. Tests rarely reach those cases. For code where such a bug is costly, run: + +```text +/create-formal-verification-skill +``` + +[`/create-formal-verification-skill`](../../skills/create-formal-verification-skill/SKILL.md) picks the target from the code: a promise the code makes, where a break harms users and tests rarely reach the case that breaks it. It picks the strongest target, not the easiest to verify. Name an area or a bug to start there instead: + +```text +/create-formal-verification-skill the job lease and retry logic. a job must never run twice at once. +``` + +The question picks the tool. Whether events can happen in a bad order suits a model checker over a small model of the design, and TLA+ is the default for that in any language. Whether the code behaves for every input suits a verifier that runs on the code. If no verifier runs on your language, the agent says so and tests the real code with generated inputs, not a hand-written model of the function. A property that must hold at every size can need a proof. The agent tells you which tool it chose and why. + +It verifies one property and shows that property failing on a broken variant of the code, because a check you've never seen fail proves nothing. When the current code already breaks the property, you get the counterexample as a short sequence of steps in your code's terms. The property stays as stated, so the finding stays a finding. Product code doesn't change unless you ask. + +It writes a project-local skill that tells the next agent whether a change touches the verified code, how to run the check with one command, and how to tell a real bug from a modeling error. Before handing it over, the generator follows that skill once on a scratch change that breaks the property, and the check must catch it. The handoff says what was verified, with which tool and bounds, what the check found, and what it doesn't cover. A pass covers the model or the bound, not the whole system. + +The skill also records which code each check covers and the commit where the check last matched it. Merges and refactors that skip the skill can leave the check passing on a description of code that's gone. After them, run [`/maintain-formal-verification-skill`](../../skills/maintain-formal-verification-skill/SKILL.md). It updates the verification from the code, reruns it, shows that each updated check can still fail, and tells you whether each failure is drift in the verification or a bug in the code. + +## Show the behavior as a statechart + +A counterexample in model terms convinces you. The PM or designer who owns the behavior needs to see it in their terms. Run: + +```text +/statechart what the user sees when they edit a note offline +``` + +[`/statechart`](../../skills/statechart/SKILL.md) draws a Mermaid statechart of what the user sees, generated from a TLA+ model, so the picture and the checked model can't drift apart. The reader's rules ("you never see Synced while you're offline") become invariants. Each bug TLC finds is a numbered path in the reader's words, and each state is marked works, still wrong, or fixed by your open PR. Before the hand-off, the agent reproduces each bug path in the real code, so a modeling error doesn't reach the reader as a bug. + +It also runs the other way. Sketch the behavior as a chart, agree on it with the reader, and the skill writes a TLA+ skeleton from it. TLC checks the agreed chart's rules before any code exists. As guards from the real code go into the spec, each place the code departs from the chart shows up as a difference for the reader to decide. + +When the code already has a TLA+ spec, the skill can audit the code against it. The agent models what the code does and checks that model next to the unchanged spec. The chart marks the steps where the two differ, and each rule either one breaks is a numbered path. ## Open the PR @@ -109,7 +145,7 @@ An open PR starts collecting blockers immediately. Checks fail, reviewers commen /meta-mode babysit this pr. get it green. ``` -Babysit watches the PR with a bundled watcher and takes blockers in order: conflicts, then review threads, then CI. Every known fix batches into one push, so the checks restart once instead of after every fix. The comment triage is skeptical, because humans and bots file real catches and noise in the same list. A real finding gets a fix, and noise gets dismissed with the disproof posted on the thread. When all you want is status, ask smaller and Babysit answers without starting the loop: +Babysit watches the PR, with a bundled watcher on GitHub or the Origin CLI when it's available, and takes blockers in order: conflicts, then review threads, then CI. Every known fix batches into one push, so the checks restart once instead of after every fix. The comment triage is skeptical, because humans and bots file real catches and noise in the same list. A real finding gets a fix, and noise gets dismissed with the disproof posted on the thread. When all you want is status, ask smaller and Babysit answers without starting the loop: ```text /meta-mode check on pr 123. anything outstanding? diff --git a/docs/guide/07-overnight.md b/docs/guide/07-overnight.md index b98d900..e24bd55 100644 --- a/docs/guide/07-overnight.md +++ b/docs/guide/07-overnight.md @@ -67,11 +67,13 @@ When you're back, ask for the run in review form: Before the skill hands back its summary, it spawns a reviewer on a different model family to read the trail and the transcript, and the reply ends with an Attention section listing what deserves your scrutiny. Read that section first, then the log rows it points at. You're auditing decisions, not re-reading the whole night. +For a quick status instead of an audit, run [`/ketchup`](../../skills/ketchup/SKILL.md) in the chat that ran the work. It reads only, and tells you in plain words what changed since your last message, with the screenshots and small diagrams the work produced. Then it lists each call waiting on you, such as a merge, an approval, or a choice between options, with its pick, the evidence, and the risk, so you can decide from the reply alone. + ## When the night holds a queue, not a task The contract above drives one task to one finish condition. Some nights hold more, a queue of independent changes or a whole program. Three playbooks scale the same trust up. -[Autopilot-full](../../skills/meta-mode/playbooks/autopilot-full.md) runs a queue of independent PRs to merged. Each PR gets one owner agent that carries it from build through merge, and no owner merges on its own verdict. A swarm of fresh verifiers starts a round at the owner's code-ready head and again at every later push that changes the patch. Only a clean verdict on the patch that merges authorizes the merge: +[Autopilot-full](../../skills/meta-mode/playbooks/autopilot-full.md) runs a queue of independent PRs to merged. Each PR gets one owner agent that carries it from build through merge, and no owner merges on its own verdict. A swarm of fresh verifiers checks the owner's code-ready head. A later rebase or minor fix gets one fresh verifier, and only new behavior or a large new diff calls the full swarm again. Only a clean verdict on the patch that merges authorizes the merge: ```text /meta-mode full autopilot on this queue. each item is independent. i want them merged by morning. diff --git a/docs/guide/08-principles.md b/docs/guide/08-principles.md index 03f9d5d..0e1b681 100644 --- a/docs/guide/08-principles.md +++ b/docs/guide/08-principles.md @@ -24,7 +24,7 @@ Say two parallel attempts are about to write to the same branch: separate before serializing shared state. give each attempt its own worktree, no locks. ``` -Each phrase lands because the rule behind it is specific. The agent still has to say, in its reply, which decision the rule changed. A principle citation with no decision behind it is the tell that it name-dropped instead of applying. +Each phrase lands because the rule behind it is specific. The agent still has to say, in its reply, which decision the rule changed. A principle citation with no decision behind it means the agent named the rule without applying it. ## The 24, briefly @@ -33,7 +33,7 @@ The core principles decide how much to build and when to rethink the design: - [Laziness Protocol](../../skills/principle-laziness-protocol/SKILL.md) prefers deletion and the smallest change that solves the problem. - [Foundational Thinking](../../skills/principle-foundational-thinking/SKILL.md) chooses the core data structures before writing logic. - [Redesign from First Principles](../../skills/principle-redesign-from-first-principles/SKILL.md) integrates a new requirement as if it had been there from day one. -- [Attack the Premise](../../skills/principle-attack-the-premise/SKILL.md) questions the premise that two or more failed fixes shared, after a census of which actors hold the imbalance. +- [Attack the Premise](../../skills/principle-attack-the-premise/SKILL.md) writes down the premise that two or more failed fixes shared and tests it directly before the next fix. - [Subtract Before You Add](../../skills/principle-subtract-before-you-add/SKILL.md) removes dead weight before building on top of it. - [Minimize Reader Load](../../skills/principle-minimize-reader-load/SKILL.md) collapses layers and hidden state a reader must hold in their head. - [Outcome-Oriented Execution](../../skills/principle-outcome-oriented-execution/SKILL.md) converges rewrites on the target design instead of preserving throwaway compatibility states. @@ -55,7 +55,7 @@ The verification principles define what counts as proof: - [Prove It Works](../../skills/principle-prove-it-works/SKILL.md) verifies the real artifact, not a proxy. - [Fix Root Causes](../../skills/principle-fix-root-causes/SKILL.md) reproduces and traces to the cause before changing code. - [Sequence Work into Verifiable Units](../../skills/principle-sequence-verifiable-units/SKILL.md) ends each small unit in a check before starting the next. -- [Test Behavior, Not Implementation](../../skills/principle-test-behavior-not-implementation/SKILL.md) calls the code the way its users do and asserts a literal expected value, and deletes a test that would still pass if every imported function returned `undefined`. +- [Test Behavior, Not Implementation](../../skills/principle-test-behavior-not-implementation/SKILL.md) asserts what the code's users can observe, so a test fails when that behavior breaks and survives a change to the internals. - [Explain the Number](../../skills/principle-explain-the-number/SKILL.md) names what limits a measured number and rules out that it measured something else, before anyone trusts or reports it. [`/benchmark-checklist`](../../skills/benchmark-checklist/SKILL.md) turns it into seven questions you answer from real runs. The delegation principles keep parallel work sane: diff --git a/docs/guide/09-make-it-yours.md b/docs/guide/09-make-it-yours.md index 26b7ee9..9c465e3 100644 --- a/docs/guide/09-make-it-yours.md +++ b/docs/guide/09-make-it-yours.md @@ -1,6 +1,6 @@ # Make it yours -meta-mode is one person's style. The machinery underneath, playbooks, routing, model roles, works just as well wearing yours. This page covers generating a personal mode, capturing lessons from a session, fixing the repo so agents stop repeating mistakes, authoring a focused skill, and testing a skill change before you trust it. +meta-mode is one person's style. The playbooks, routing, and model roles underneath it work just as well with yours. This page covers generating a personal mode, capturing lessons from a session, fixing the repo so agents stop repeating mistakes, authoring a focused skill, and testing a skill change before you trust it. Start smaller than you think. You don't need many skills on day one, or even this whole plugin. Prompt plainly, watch where agents fail, and add a skill or a check when the same failure shows up twice. @@ -18,7 +18,7 @@ Run it again whenever your habits drift: /automate-me update my mode skill with everything since its last edit ``` -Update mode mines only the history since the skill last changed. It keeps rules you haven't contradicted, revises the ones with new evidence, and adds sections only for genuinely new patterns. +Update mode mines only the history since the skill last changed. It keeps rules you haven't contradicted, revises the ones with new evidence, and adds sections only for new patterns. ## Capture a session's lessons with `/reflect` @@ -59,7 +59,7 @@ When you already know the workflow you want to capture: Writing a skill matches the [Authoring or modifying a skill playbook](../../skills/meta-mode/playbooks/authoring-a-skill.md), which routes through the active Harness's skill-authoring flow, validates the frontmatter and links, and ships the result through the Opening a PR playbook. Agent-facing prose has a higher bar than human prose, because an unhelpful sentence becomes an instruction some future agent follows. Let the playbook hold that bar rather than writing a `SKILL.md` freehand. -One special case has its own generator. A skill that must drive your app and prove behavior is a verification skill, so use [`/create-verification-skill`](../../skills/create-verification-skill/SKILL.md) and [`/maintain-verification-skill`](../../skills/maintain-verification-skill/SKILL.md) instead. [Verify and ship](./06-verify-and-ship.md#create-a-project-verification-skill) covers both. +Two special cases have their own generators. A skill that must drive your app and prove behavior is a verification skill, so use [`/create-verification-skill`](../../skills/create-verification-skill/SKILL.md) and [`/maintain-verification-skill`](../../skills/maintain-verification-skill/SKILL.md) instead. A skill that checks a property of your code with a model checker, a proof, or another verifier is a formal verification skill, so use [`/create-formal-verification-skill`](../../skills/create-formal-verification-skill/SKILL.md) and [`/maintain-formal-verification-skill`](../../skills/maintain-formal-verification-skill/SKILL.md). [Verify and ship](./06-verify-and-ship.md#prove-a-property-with-a-formal-verification-skill) covers both kinds. ## Write docs to a standard with `/technical-writing` @@ -69,7 +69,7 @@ Skills aren't the only prose you ship. For docs, RFCs, readmes, PR descriptions, /technical-writing review the readme changes ``` -[`/technical-writing`](../../skills/technical-writing/SKILL.md) applies a layered standard with one goal, prose a tired engineer understands on the first read. It picks the document's mode first (tutorial, how-to, reference, or explanation), then works sentence by sentence: who does what, one thought per sentence, nothing readable two ways. Use it to review what you or an agent just wrote, or name it up front when you ask for a doc. +[`/technical-writing`](../../skills/technical-writing/SKILL.md) applies a layered standard. Its goal is prose that a tired engineer understands on the first read. It picks the document's mode first (tutorial, how-to, reference, or explanation), then works sentence by sentence: who does what, one thought per sentence, nothing readable two ways. Use it to review what you or an agent just wrote, or name it up front when you ask for a doc. ## Test a skill change blind diff --git a/docs/guide/10-recipes-and-pitfalls.md b/docs/guide/10-recipes-and-pitfalls.md index 685a65d..efc2888 100644 --- a/docs/guide/10-recipes-and-pitfalls.md +++ b/docs/guide/10-recipes-and-pitfalls.md @@ -18,7 +18,15 @@ Mechanics first, history second. Each skill's report tells you which sources it /meta-mode read this thread. restate the underlying issue in your own words, in plain english. don't change any code yet. ``` -A 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. +A 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. + +## Sharpen a prompt before you send it + +```text +/sharpen write the onboarding doc for the new billing service. +``` + +The 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. ## Prototype before you pick @@ -42,7 +50,7 @@ Ask only after the design settles. The plan is the deliverable, and it names the ask /arena for a second opinion on this thread and our approach ``` -Your current design becomes one candidate among several, and the synthesis tells you whether the panel found something better or confirmed what you had. Cheap insurance before a costly commitment. +Your current design becomes one candidate among several, and the synthesis tells you whether the panel found something better or confirmed what you had. It costs little next to a design that is expensive to reverse. ## Check independent slices in parallel @@ -76,6 +84,14 @@ The qualifiers do real work. "don't change anything yet" keeps it read-only, and "if it repros on main" lets the run stop early when the bug is already gone. The video lets you check the fix before you read the diff. +## Prove a property tests rarely reach + +```text +/create-formal-verification-skill the retry queue. a message must never be delivered twice. +``` + +You get one property checked in every case within a stated scope, a counterexample in your code's terms if the code already breaks it, and a project skill the next agent runs on any change to that code. + ## Vet a number before you post it ```text @@ -126,6 +142,14 @@ You rarely need more words. You need the right name, and [the principles page](. That's the whole prompt. [`/bro`](../../skills/bro/SKILL.md) restates the last message like one human talking to another, no jargon, shorter. Use it when a reply is technically thorough and you still don't know what it said. +## Catch up on a chat you left running + +```text +/ketchup +``` + +[`/ketchup`](../../skills/ketchup/SKILL.md) tells you in plain words what changed since your last message, then lists each call waiting on you with its pick, evidence, and risk. It changes nothing while it reports. + ## The pitfalls - **Enumerating skills in the prompt.** "use /how then /architect then /arena" reorders steps the playbook already sequences. State the goal and constraints. Name a skill only to override a default. @@ -133,7 +157,7 @@ That's the whole prompt. [`/bro`](../../skills/bro/SKILL.md) restates the last m - **Leading with your theory of the cause.** The agent searches wherever you pointed. Ask it to restate the problem first, then share your hunch. - **Taking the first design.** One attempt locks in the first shape the model thought of. Ask for prototypes or `/architect` and pick from evidence. - **Polishing an abstract plan.** Adversarial review of a plan with no code behind it invents risks that will never happen. Settle the open questions with prototypes, then review what got built. -- **Parallel agents in one worktree.** They overwrite each other and the diff becomes archaeology. Run them on separate machines, or say "own worktree per attempt". +- **Parallel agents in one worktree.** They overwrite each other, and nobody can tell which agent changed what. Run them on separate machines, or say "own worktree per attempt". - **Looping before you trust the loop.** A loop that can't verify its own work only makes unchecked work faster. Get the verification skill working first. - **Trusting an unvetted number.** A warm cache or a skipped code path can fake a speedup. Run `/benchmark-checklist` before the number goes anywhere. - **Correcting the same mistake by hand.** A correction in chat helps one run. `/correct` fixes the repo so no later run repeats it. diff --git a/docs/guide/README.md b/docs/guide/README.md index af7a4ce..eb1ef29 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -9,7 +9,7 @@ Here's what you'll learn: 3. [Understand the code](./03-understand.md). A read-only investigation, then `/how`, `/why`, `/teach`, and `/recall` before you edit anything. 4. [Design the change](./04-design.md). `/architect`, `/arena`, `/swarm`, `/interrogate`, prototypes, and plans before code locks in a shape. 5. [Build and clean the change](./05-build-and-clean.md). The build playbooks, `/tdd`, `/unslop`, and `/no-comments`. -6. [Verify and ship](./06-verify-and-ship.md). Prove behavior on the real app, vet numbers with `/benchmark-checklist`, then open a focused PR and drive it to merged. +6. [Verify and ship](./06-verify-and-ship.md). Prove behavior on the real app, formally verify what tests rarely reach, vet numbers with `/benchmark-checklist`, then open a focused PR and drive it to merged. 7. [Run work while you sleep](./07-overnight.md). Trust before loops, an overnight contract, a decision log you can audit, and Projects and automations that scale past one agent. 8. [Steer with principle names](./08-principles.md). The 24 names that redirect an agent mid-task. 9. [Make it yours](./09-make-it-yours.md). Your own mode, `/correct` for repeated mistakes, and how to test a skill change. diff --git a/docs/harness-adapters.md b/docs/harness-adapters.md index 9decd17..c4941b8 100644 --- a/docs/harness-adapters.md +++ b/docs/harness-adapters.md @@ -21,13 +21,13 @@ Sources: - [Claude Code skills](https://code.claude.com/docs/en/skills) - [OpenCode skills](https://opencode.ai/docs/skills) - [pi skills](https://pi.dev/docs/latest/skills) -- Antigravity: the `agy` 1.2.16 CLI and T3 Code's skill discovery for it, +- Antigravity: the `agy` 1.2.17 CLI and T3 Code's skill discovery for it, [`AntigravitySkills.ts`](https://github.com/pingdotgg/t3code/blob/main/apps/server/src/provider/Drivers/AntigravitySkills.ts) and the [Antigravity provider guide](https://github.com/pingdotgg/t3code/blob/main/docs/user/providers-antigravity.md). Antigravity publishes no skill-path reference, so the roots above come from that implementation. - Grok: the Grok Build user guide shipped with the CLI (`docs/user-guide/08-skills.md` - under the Grok home directory), checked against `grok inspect --json` on 1.0.46, + under the Grok home directory), checked against `grok inspect --json` on 1.0.62, which listed skills from `~/.agents/skills/`. - [Cursor skills](https://cursor.com/docs/context/skills) @@ -72,14 +72,14 @@ also scans `~/.claude/skills/`; skills with the same name collapse to one entry. Claude output omits frontmatter `name`; Claude's documented fallback uses the directory name, so `/meta-mode` keeps its name. OpenCode requires an explicit name before registration and skips the Claude copy. This behavior -was checked with OpenCode 1.18.30 using a named Claude control skill and an +was checked with OpenCode 1.19.3 using a named Claude control skill and an unnamed Claude copy alongside the canonical shared skill. Claude Code 2.1.267 also returned the directory-derived command name during an isolated stream-JSON initialization without a user prompt or model request. Recheck this adapter if OpenCode adds a directory-name fallback for external skills. pi keeps the first same-name skill and reports a collision for later -independent copies. This was observed with pi 0.85.1, and the pi 1.1.0 skills +independent copies. This was observed with pi 0.85.1, and the pi 1.3.0 skills documentation states the same rule. A stale native pi copy can therefore hide a shared update. The installer migration removes recognized redundant copies from discovery; see [migration instructions](../README.md#migrate-an-existing-installation). diff --git a/docs/t3code.md b/docs/t3code.md index 3c2b802..7ecc673 100644 --- a/docs/t3code.md +++ b/docs/t3code.md @@ -2,7 +2,7 @@ [T3 Code](https://github.com/pingdotgg/t3code) is a GUI that runs Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Pi, and ACP Registry agents as subprocesses. It does not own skills. Each provider finds its own skills, and T3 lists them in the composer. Installing mstack for a provider is therefore enough; nothing is installed into T3 itself. -This page was checked against the T3 Code source at commit `8f75697` (October 2026), the Grok CLI 1.0.46, and the mstack installer in this repository. T3 changes quickly, so treat the tables as a starting point and use the checks in each section when something does not show up. +This page was checked against the T3 Code source at commit `8f75697` (October 2026), the Grok CLI 1.0.62, and the mstack installer in this repository. T3 changes quickly, so treat the tables as a starting point and use the checks in each section when something does not show up. ## How T3 finds skills @@ -11,7 +11,7 @@ This page was checked against the T3 Code source at commit `8f75697` (October 20 | Codex | Codex app-server (`skills/list`) | Codex's own, `~/.agents/skills` for mstack | Codex's own, `.agents/skills` for mstack | | Claude Code | T3 scans the directories | `/skills`, where the config dir is the instance's home path, else `CLAUDE_CONFIG_DIR`, else `~/.claude` | `/.claude/skills` only | | Cursor | T3 scans the directories | `~/.cursor/skills`, `~/.agents/skills`, `~/.codex/skills`, `~/.claude/skills` | The same four names under `` | -| Grok | `grok inspect --json` | Grok's own; on Grok CLI 1.0.46 both `~/.agents/skills` and `~/.claude/skills` were reported | Grok's own, not checked | +| Grok | `grok inspect --json` | Grok's own; on Grok CLI 1.0.62 both `~/.agents/skills` and `~/.claude/skills` were reported | Grok's own, not checked | | Antigravity | T3 scans the directories | `~/.gemini/config/skills`, `~/.gemini/antigravity-cli/skills`. Not `~/.agents/skills`. | `/.gemini/skills`, `/.agents/skills`, `/.agent/skills` | | Pi | Pi (`get_commands`) | Pi's own | Pi's own; approve the project in Pi first | | OpenCode | OpenCode, per project directory | OpenCode's own | OpenCode's own | @@ -27,7 +27,7 @@ This page was checked against the T3 Code source at commit `8f75697` (October 20 npx @3metajun/mstack --harness codex,claude,pi ``` -- Codex, Pi, OpenCode, and Cursor read the shared copy. Grok read it on 1.0.46. +- Codex, Pi, OpenCode, and Cursor read the shared copy. Grok read it on 1.0.62. - Claude Code needs its own copy in `~/.claude/skills`, which `--harness claude` writes. The installer leaves out the frontmatter `name` there because Claude Code names a skill after its directory. - Antigravity does not read `~/.agents/skills`, and it needs the frontmatter `name`, so the Claude copy does not work for it either. Use an `antigravity` harness if your mstack version has one (`npx @3metajun/mstack --help` lists the harnesses). Otherwise install the canonical copy into its directory by overriding the Codex target: diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 05208e8..652c7c3 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -1,7 +1,7 @@ { "version": 1, "sources": { - "pstack": "11713c683fe4add4fd403ea880580abbc9e0875f", + "pstack": "eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10", "mattpocock/skills": "49dd158d1076134a641b33efb035946536778336" }, "files": { @@ -32,13 +32,13 @@ "skills/arena/SKILL.md": { "upstream": "pstack", "source": "skills/arena/SKILL.md", - "sourceDigest": "f15524f216c1c63804b89edb4ff8e53bb21a7b8ed367368dedeb9468eeec6a92", + "sourceDigest": "ed72f6c132b1ce3974d949581d0adc5dade424fe20793ac3b4c4fae22ecb9354", "targetDigest": "f100383e190b806567015d2cbbaf90b9a1b0151ae60b08aa6185fc6eca3c9552" }, "skills/automate-me/SKILL.md": { "upstream": "pstack", "source": "skills/automate-me/SKILL.md", - "sourceDigest": "1510979b0b71b54284427a19c09918f8b1fdfc168f9b7f9e30098793a5ec88ef", + "sourceDigest": "30b35f3628c4fd8c11af0ada06d14cdc3b28901810caca2480f627d4d21acfbe", "targetDigest": "6ab6c4c4a4b50a7cb182149408bd1758229d4386fbb8b1a66cf6927847b02c27" }, "skills/benchmark-checklist/SKILL.md": { @@ -82,9 +82,15 @@ "skills/correct/SKILL.md": { "upstream": "pstack", "source": "skills/correct/SKILL.md", - "sourceDigest": "bf482bacc972b3f124a4af8bf9364b4b3e280cb5845e44c326ea4f4565e5c904", + "sourceDigest": "e347045d00c967a0b831ef883cc6ac79d21329aa10b5968637df95de7febf0ba", "targetDigest": "b7af9686e96f242ddd7d0c12731dd8c4347f4eff1c6d404dc87403d87076d492" }, + "skills/create-formal-verification-skill/SKILL.md": { + "upstream": "pstack", + "source": "skills/create-formal-verification-skill/SKILL.md", + "sourceDigest": "6c934cd15a9583533ff9b7a9cad62775d7e80402d26d3ba7f74f79ea21a1d8eb", + "targetDigest": "e1a3ccafaf3383c897e87a2b6cb1f93033ae42ce9555fd8e87947f24648d67c7" + }, "skills/create-verification-skill/references/feature-map-example/create-note.md": { "upstream": "pstack", "source": "skills/create-verification-skill/references/feature-map-example/create-note.md", @@ -116,7 +122,7 @@ "skills/create-verification-skill/SKILL.md": { "upstream": "pstack", "source": "skills/create-verification-skill/SKILL.md", - "sourceDigest": "644f2551403c1bca01a2855b34611b6e7be0ce0dc5b204514c376c0f6a6e6ac4", + "sourceDigest": "d353973547bf8ad8977eca645f4cd4feaed0d32f4b43d1448f906b0a76c4d694", "targetDigest": "eca219cac2808714d6aa3090fd3065d1da6a579d432f277aef334f1ca0dfe770" }, "skills/diagnosing-bugs/references/feedback-loops.md": { @@ -144,7 +150,7 @@ "skills/figure-it-out/SKILL.md": { "upstream": "pstack", "source": "skills/figure-it-out/SKILL.md", - "sourceDigest": "d9574cc24929773d5c23f40626ae1e09660afddd7de48e08dd6494d9fff9bbf0", + "sourceDigest": "bdb23cdbd82a1e6af094b495691c832a6f7f3ab9706ba9edbeb664ef51a726e1", "targetDigest": "37c25a3c0b354da74e33a2a291fc21e62140304eee2447ed73400bc2759402ec" }, "skills/how/references/explainer-prompt.md": { @@ -156,15 +162,21 @@ "skills/how/references/explorer-prompt.md": { "upstream": "pstack", "source": "skills/how/references/explorer-prompt.md", - "sourceDigest": "a3b44a6e5bb16a6d4d1cac19db8ed5322ffd5f5f5d45baca4e73990ca096f8be", + "sourceDigest": "6300128385ee940fd42ecb84b0e623435440a2dc283eae54ee4a52ba56947ba1", "targetDigest": "a3b44a6e5bb16a6d4d1cac19db8ed5322ffd5f5f5d45baca4e73990ca096f8be" }, "skills/how/SKILL.md": { "upstream": "pstack", "source": "skills/how/SKILL.md", - "sourceDigest": "47cfe3e6fe55c49c3bdf8ad1b645dcd5fece8f0c36fd22d2e7221f9faa71f648", + "sourceDigest": "08a00079bb15b4258874ac3ff62d4ca7e94350ffedb521d0fd296dc9dd21904e", "targetDigest": "a3e2acb068e126bad1ddadb4b16bb14154d5687fffc8ce2fe8209660b83c2e4b" }, + "skills/in-your-own-words/SKILL.md": { + "upstream": "pstack", + "source": "skills/in-your-own-words/SKILL.md", + "sourceDigest": "fa8ba74e27d7be371dc270ec78bbf816411477d0dfab185356c861ac8dd413ad", + "targetDigest": "292b1f5b008a78be49830f02f8bc04be7bf0df50e66d0143138f244f2b47a38a" + }, "skills/interrogate/references/code-quality-review.md": { "upstream": "pstack", "source": "skills/interrogate/references/code-quality-review.md", @@ -174,7 +186,7 @@ "skills/interrogate/references/lead-judgment.md": { "upstream": "pstack", "source": "skills/interrogate/references/lead-judgment.md", - "sourceDigest": "6e5bdf5670eb34017692e9b6c546f36ac7552e6bb9f3f2f3429164fcc46a9361", + "sourceDigest": "355f5195a76bc75a655125292369f2a8231a2b61fabe5188d8f1386060922b87", "targetDigest": "6e5bdf5670eb34017692e9b6c546f36ac7552e6bb9f3f2f3429164fcc46a9361" }, "skills/interrogate/references/reviewer-prompt.md": { @@ -192,15 +204,21 @@ "skills/interrogate/SKILL.md": { "upstream": "pstack", "source": "skills/interrogate/SKILL.md", - "sourceDigest": "25341c393f515c1e1fa9bce668b7551e9f98c195113a460ff524d3c1f7f50cd9", + "sourceDigest": "a16dfe179005abc22a4651b82f25b0c8ab953284276efe4e6dc341958fb63647", "targetDigest": "ab2508809da00eac47444b102cd7d11730f15b2476cd6cd947e330cc08814e78" }, "skills/ketchup/SKILL.md": { "upstream": "pstack", "source": "skills/ketchup/SKILL.md", - "sourceDigest": "f460a861cf2179808abc24b5588c94709f8cc3e58d4da2e44baf89556f53b587", + "sourceDigest": "f2923ffd14f6fc778f75a47aaed1f317fb5fd00b5701ca769d6446c74106dead", "targetDigest": "451887dc45227e0e1abd9abfb043e0bc231ff0b246a8ed48b05786306ada123a" }, + "skills/maintain-formal-verification-skill/SKILL.md": { + "upstream": "pstack", + "source": "skills/maintain-formal-verification-skill/SKILL.md", + "sourceDigest": "0c96f54293542d3818b68754562b338073958781a9684454611dca37592e2d2c", + "targetDigest": "72ada37e2478af4c3902ed7b2e858e930cbbf06625e0001cbdc8c5612965964f" + }, "skills/maintain-verification-skill/SKILL.md": { "upstream": "pstack", "source": "skills/maintain-verification-skill/SKILL.md", @@ -210,7 +228,7 @@ "skills/make-bot-ui/SKILL.md": { "upstream": "pstack", "source": "skills/make-bot-ui/SKILL.md", - "sourceDigest": "3624c8ef16cf2e48deb2a4049e72c6962a9431c8d6c6af90e7bd7ea0375224ec", + "sourceDigest": "ad19d0bc6764287ec08c2521701974e7dac941f72e4d0fe81a24f4614494586f", "targetDigest": "2f04984c34d64ad8119eac43f24298d480528b14a9876d672aee0c942aa0c2da" }, "skills/meta-mode/playbooks/authoring-a-skill.md": { @@ -222,49 +240,49 @@ "skills/meta-mode/playbooks/autonomous-run.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/autonomous-run.md", - "sourceDigest": "15e54ad3ac92565613980772d8708de6e19cc4690bfd4bb3fb8836522407377b", + "sourceDigest": "6ac974fd2b09748add56493e89f578a35a6a9289e1026515878d81ef3cf129d1", "targetDigest": "280bc69056d1e4c5c5df85821e191719483e1f5475b445eaae205dc242882d0e" }, "skills/meta-mode/playbooks/autopilot-full.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/autopilot-full.md", - "sourceDigest": "05ca30a16242c19d0c78ba1e88cca66f858d75252cb154fd740099fe292de802", + "sourceDigest": "acf4f4a58c11045f9d3e8807d907a877e61ca6432b079f1087d7d30fc931566f", "targetDigest": "84f3c21ea3af7f3d7b30d800e067e552f0e5db047c7553229a7b2fb5559dfdab" }, "skills/meta-mode/playbooks/autopilot-stack.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/autopilot-stack.md", - "sourceDigest": "007c0a3b12011954a224f54773ef4ad7b75918be0a311914c1b51ad4e794c1e5", + "sourceDigest": "c6bcb18b2ce605e77a2a2225854590a4b9a687911f79331746d91a4fe9a52e60", "targetDigest": "e822c24e34738087a24f65abc218d31c8c3c08436e7ee04d5c67fd00fc596a03" }, "skills/meta-mode/playbooks/babysit.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/babysit.md", - "sourceDigest": "a0346c37c2313f36a863b80f66ee7b98059c2242adf74fc7b3e48abdb9ec6f5a", + "sourceDigest": "e261c1ff5581e0f8fc6c686f34de4b535f87473e77beabea7dd8323021214610", "targetDigest": "679067250cf57da21cdc8b0bd3215b1149ec9a1e72ffe92955c3dd34a0d1c8e7" }, "skills/meta-mode/playbooks/bug-fix.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/bug-fix.md", - "sourceDigest": "018bbc146ae19145caa6c7e70591fee081b64447dda1c1c4b681c9974d928b13", + "sourceDigest": "66e26542dad131cfd7df80ac0cdf81ed4d85e7b5e3f0eb65b933888760c4831f", "targetDigest": "8872387c8893159c2557929af3c2b35270b245ace9aa04b76f3d8f622b526d2d" }, "skills/meta-mode/playbooks/eval.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/eval.md", - "sourceDigest": "b862bfa2fae2399abf047a8e80d3e3dddb64003af96fb1cebb625216131e36bb", + "sourceDigest": "a93d449a908236afa6645179651844094b1547dbab4e484cffcdda630e4b90bf", "targetDigest": "7541b311046f930691e77ab6a71f23b2587a41cbff4804d4b8d097adc92be4d7" }, "skills/meta-mode/playbooks/feature.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/feature.md", - "sourceDigest": "3a62cc4a8cbcf144bb9dd05f05420a15be0d38b784d3b141bb730a5852f50a62", + "sourceDigest": "af8ce700b99953741cb547e34308fde0cf6b98ce67e1652c800d0deedf53aba1", "targetDigest": "f9ac3784c615c1e84d40ec30ede16872988dcb8364d480343e4d98b7961f06c2" }, "skills/meta-mode/playbooks/hillclimb.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/hillclimb.md", - "sourceDigest": "244f289898fe67a458065e90fe8217c916c5acf61cdee8975ade3f9831a12e91", + "sourceDigest": "942c1565b75ad02a2cb6230dd7c1ebd87e19f59a00319ebac2b6616007bf018f", "targetDigest": "656c3f4c2e78d227e90eda89f4262f251db7f4e6f1e8ad13095cc9f1d86564e5" }, "skills/meta-mode/playbooks/investigation.md": { @@ -276,19 +294,19 @@ "skills/meta-mode/playbooks/multi-phase-plan.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/multi-phase-plan.md", - "sourceDigest": "27d310e3fc3937a60b8fcbe1318f9d249a7477a722907bf85b3e4eb3dc436b11", + "sourceDigest": "5f239457bfdec3345c92caa06c59bf0257c662b89353f25a1c02ea942dcd00b1", "targetDigest": "bef154d16534d0af83f5742285b3d354f2aa337ff50bca7e3e169343edcab002" }, "skills/meta-mode/playbooks/opening-a-pr.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/opening-a-pr.md", - "sourceDigest": "931070f5674e81a191277253d4e02b1c45d091b223f93bf506d3896a428a5967", + "sourceDigest": "a5aef646de8b651306c2d10974623650e4a013a245aec1c00525bbe0e31bfa76", "targetDigest": "a44b4ea2e72e5249eb0ebe26fe9d64317285cc9eb2fd84382e50329e30fc89be" }, "skills/meta-mode/playbooks/orchestrate.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/orchestrate.md", - "sourceDigest": "d42b4375f1bd4ce23da0b575a198571a2117df7eea10c3c9fc6d92ae1053f7be", + "sourceDigest": "08dce6c9e7be1d13bc6944ab9b51665f935c531437f5e088bc53d2a62099ef05", "targetDigest": "9e898ebeb4225b6b819d93c5b198d9c4bf84e8a5fd9b5d60977854237f5d296e" }, "skills/meta-mode/playbooks/pause-safely.md": { @@ -300,7 +318,7 @@ "skills/meta-mode/playbooks/perf-issue.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/perf-issue.md", - "sourceDigest": "2b88ad0f880e4d4d755ac19fe52414d6631d6baa816d060f53cf0a7429d7e14a", + "sourceDigest": "c12aa2f82de7651a11676a3d5fea168f539b7f0faaa3a501d279255dd2a469c8", "targetDigest": "c452f37e6461e22eb5accf5231a92c1fee17813e702a815207cab614cf52c3e4" }, "skills/meta-mode/playbooks/prototype.md": { @@ -312,31 +330,31 @@ "skills/meta-mode/playbooks/refactoring.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/refactoring.md", - "sourceDigest": "50f4f7d3d301551f8b8a57c3303c99958c5ca630ae0d0051823b6987c0eb5b96", + "sourceDigest": "0f5dd38113379ea8cf61e2182eca0a7478eea9a869eb40c2dddab3335aeca197", "targetDigest": "81761553c8cb8a76beb38184b2c2f2a0d419aa64c987cc7896f4771cd7d4e021" }, "skills/meta-mode/playbooks/runtime-forensics.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/runtime-forensics.md", - "sourceDigest": "594f02c40c243a346e1f57b688ecf2ed5e882f21b3d24ed2204d1ac6f1c24a8a", + "sourceDigest": "3463372b578c28f27c236f2627f09b5c5b1e85e86db7399fb984f3892fb450f5", "targetDigest": "594f02c40c243a346e1f57b688ecf2ed5e882f21b3d24ed2204d1ac6f1c24a8a" }, "skills/meta-mode/playbooks/session-pickup.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/session-pickup.md", - "sourceDigest": "1604fb463f7c7410d43fa024ca76c1c1824c166a01fd69d61f073d06fdca2a47", + "sourceDigest": "d194f2a7ef392c1598ef3f0e3b561370ca25c2c7a78308007af4aa46ada8ee12", "targetDigest": "e141647ec8951307806f0c5d25f6f540171770e81851bc0bebf0aed2540ee8b8" }, "skills/meta-mode/playbooks/shipping.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/shipping.md", - "sourceDigest": "19f3f6803a7bbe9ff04f624b03e3b55f07ac0a05e991aa00d5fe51239b920b2a", + "sourceDigest": "bf56339bdf7e0223bcff5abf4e8599e3f5b547b3fcc3b4ce9475e6e7dda14a9f", "targetDigest": "bd144d61ab82bb3cf4b0a2c9bc0310e8fdfa98e74baccce3528d5141895c5796" }, "skills/meta-mode/playbooks/trace-forensics.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/trace-forensics.md", - "sourceDigest": "e8a671fd5511a894f90c933bca75815e7a1d730cb53333075a80b0927e81acfc", + "sourceDigest": "ef0bbf79856a4ae1a8993d54c1a1da159cb97623941b652c0069cf75a017e0f5", "targetDigest": "137ce3984fb96e45971fdff4d3c79fa4ccc647c5b30ae4945783b9fb23f8cec7" }, "skills/meta-mode/playbooks/visual-parity.md": { @@ -348,7 +366,7 @@ "skills/meta-mode/playbooks/worktree-cleanup.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/worktree-cleanup.md", - "sourceDigest": "fc464dc4121926df1704f483c5feca83717c0a40b4651d3e11bd3aedd89441b6", + "sourceDigest": "be04ae0afe19d4a4da5bcc4e94455f68dbebd1cda6eb0f456ca0d358ecf76c00", "targetDigest": "7a13c4a1e27e084f9bb9ce9f7c998d5584efeb3e4bfa15a6422f261f9db0bc77" }, "skills/meta-mode/references/bugbot-triage.md": { @@ -375,7 +393,7 @@ "skills/meta-mode/SKILL.md": { "upstream": "pstack", "source": "skills/poteto-mode/SKILL.md", - "sourceDigest": "03d61d15c5a0f7abc2c558c7323ff3c360dedc2915ebadf3c76c9e872f41cb64", + "sourceDigest": "2755f102e7a6d0496b429d1031a22fb353e1f3d523543367caeaae5c4ed6f544", "targetDigest": "1bd904d7163d624d390c09e7bd1079bb5dcf3435828ee72fa34882e06f347c52" }, "skills/mstack-help/SKILL.md": { @@ -386,13 +404,13 @@ "skills/no-comments/SKILL.md": { "upstream": "pstack", "source": "skills/no-comments/SKILL.md", - "sourceDigest": "5c5b0882297d704c3a9720c52b7a793c68b013eaf717989f0945624efdfe2b05", + "sourceDigest": "078ecfe224eafbb017f6476870612838bf6dbe38c0b697e12f0c887d0313f17e", "targetDigest": "f5bc3892fd057751d5928cffbebabbcde58804021a520bf67fac6b550d3399f5" }, "skills/principle-attack-the-premise/SKILL.md": { "upstream": "pstack", "source": "skills/principle-attack-the-premise/SKILL.md", - "sourceDigest": "c87bd7536f772f8a403ad8155535ee17a1bbec9a353156693eb3bb1520c87ae3", + "sourceDigest": "6a932884daad4aa189e9b5d1665226bbc75621d4d2a398fd4003bebd382f4599", "targetDigest": "58c2998cbe0318b648d2f78c080003c5d3685a0ede2a98e56bc29e2fdfa7417a" }, "skills/principle-boundary-discipline/SKILL.md": { @@ -410,13 +428,13 @@ "skills/principle-encode-lessons-in-structure/SKILL.md": { "upstream": "pstack", "source": "skills/principle-encode-lessons-in-structure/SKILL.md", - "sourceDigest": "ad12c26209770c52f632d9480c048ac5334ce8e5d6297f83ade59652a56cb3ef", + "sourceDigest": "228963799189f481827d6f28065f278bc2008ffa37030b1234cbd5814d36b9e9", "targetDigest": "e66d95bd954a1e8ba298bc451ba1e9190c758f6d77c240540bedf8f28c895694" }, "skills/principle-exhaust-the-design-space/SKILL.md": { "upstream": "pstack", "source": "skills/principle-exhaust-the-design-space/SKILL.md", - "sourceDigest": "8583310d7297b7823d75494faa57b003564d436807febecdecbd8321abe5cf6e", + "sourceDigest": "2de83d99f93f60ec6313455b0bc9aa9f0bfb93dd59f414f403691322db00d4db", "targetDigest": "67e7e29ab4f484bfddcac55ece4d70f6efc25ccbf91de7de31fcec385f9f059e" }, "skills/principle-experience-first/SKILL.md": { @@ -434,7 +452,7 @@ "skills/principle-fix-root-causes/SKILL.md": { "upstream": "pstack", "source": "skills/principle-fix-root-causes/SKILL.md", - "sourceDigest": "ba4cd38da1dcc8fe5432feb64457cba95594736a9b21b2ebf8f8c52aee068707", + "sourceDigest": "8df3c8c3fe57eb0055a5c8efab8ab144557fdf301da70b1cdc77a3a3ef3f3052", "targetDigest": "2f48a508674c8b9f3339aba95929079af54fb7fbd367b4eb45175388f1e170f9" }, "skills/principle-foundational-thinking/SKILL.md": { @@ -446,25 +464,25 @@ "skills/principle-guard-the-context-window/SKILL.md": { "upstream": "pstack", "source": "skills/principle-guard-the-context-window/SKILL.md", - "sourceDigest": "d2cef147862576e3d709b7afc149d85196245e8338b9c7eef337c95fba412b3e", + "sourceDigest": "af0b9dd70b43628ce4a59a4d332e9d9ad055084e460f430e7a757495c627e81a", "targetDigest": "7a0a9540d4c0e8053ace79628d0a493444a82d1bbe1d8e94c8e561606c3d2912" }, "skills/principle-laziness-protocol/SKILL.md": { "upstream": "pstack", "source": "skills/principle-laziness-protocol/SKILL.md", - "sourceDigest": "9006579e9e3f33955d17569733db688ae65f22577dc3b21803d219a5a8445aff", + "sourceDigest": "1be6e7a435f26a75a76eb848b9c3d611b9977d0f5822361753244da25ca3ca45", "targetDigest": "f45159ad7ea5f01a08d364897307f69ca18fa57b87d6ed6b89ca222a2388bf7d" }, "skills/principle-make-operations-idempotent/SKILL.md": { "upstream": "pstack", "source": "skills/principle-make-operations-idempotent/SKILL.md", - "sourceDigest": "540738217c3da7bf513b9886924ad6a2bc77e58be867f5bd2e1831c74bf2ce70", + "sourceDigest": "7aa9327cf1f1aa057e95d47a0a78bd88a8f9d556b608866fee939d455a53a019", "targetDigest": "5cf54da6cc34714fbe97e45fd61a4678662d087d7f6f6dc81cace3f34041f541" }, "skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md": { "upstream": "pstack", "source": "skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md", - "sourceDigest": "09978915a4a11990cf0a9a02d9c08cc59e0b7a04c5c24b83aa560fbe4146f75b", + "sourceDigest": "580ad9bb1a0994ecb924321ba49efc38a2f85c64e22c76fb133f57a78272a2e6", "targetDigest": "388e9cbc9e52bd72d9044d7e37126bd3355c4e8f1188447e2a6850fe4fc549d7" }, "skills/principle-minimize-reader-load/SKILL.md": { @@ -524,13 +542,13 @@ "skills/principle-test-behavior-not-implementation/SKILL.md": { "upstream": "pstack", "source": "skills/principle-test-behavior-not-implementation/SKILL.md", - "sourceDigest": "b2b73ba42920f9c3174ecdf808ec6e4591b3a9266ada9a05814f386004266d3a", + "sourceDigest": "f7223698623b6a26fea49c751e466b38e6691b1f32ce841d3983e7ffbe2b74a6", "targetDigest": "c6104107aa4902342d36eb5c6b7f99862eadff5ba27f367cde011ccdde4d8d17" }, "skills/principle-type-system-discipline/SKILL.md": { "upstream": "pstack", "source": "skills/principle-type-system-discipline/SKILL.md", - "sourceDigest": "2e492cef34bb25a20b9115df61f9ef3a9837e3fda144ffda8079e5f45a1b72e8", + "sourceDigest": "5cdaf79785e3c12b2592b9469ee8c272143eb86ab7a18d8382197cea5fd30106", "targetDigest": "9397a9ce6e26e54d0e6e9f81e488f626f3daefd6903509536fbdfb0d021a274f" }, "skills/recall/references/history-sources.md": { @@ -551,7 +569,7 @@ "skills/recall/SKILL.md": { "upstream": "pstack", "source": "skills/recall/SKILL.md", - "sourceDigest": "d265b0e5b6dc5b89c0407f3387d24462c988e1990a37d5359735137908128c5a", + "sourceDigest": "b0ceb996259c3404532f61e4b010df4e3d80d7ddbed85b3c302895723eb62a89", "targetDigest": "be67c9e064a2b6d2ec52e915c19f6507f9f389a112e9961eb93b3c793b565e70" }, "skills/reflect/references/divergent-reviewer.md": { @@ -581,7 +599,7 @@ "skills/reflect/SKILL.md": { "upstream": "pstack", "source": "skills/reflect/SKILL.md", - "sourceDigest": "f5e9200f37e2a4567c0eeb0ba244fde66c1b3f35e3b7a883de2f85b3bda7365a", + "sourceDigest": "1f5a8c246cdee8251f8667d85bd5d97f6c2801f505843c26ddbd6c621632519b", "targetDigest": "5478382b85614482fdf8799c9b0113d0c12c6595ee1003aa62c91f6288b91089" }, "skills/setup-mstack/references/hosts/t3code.md": { @@ -592,9 +610,15 @@ "skills/setup-mstack/SKILL.md": { "upstream": "pstack", "source": "skills/setup-pstack/SKILL.md", - "sourceDigest": "d61b47256a18155a81c8e5ef95e3cc6569ce8c18317c35c4dd9c8015cf68cb59", + "sourceDigest": "b45512f6ca5e96e81681984683b7472bfe892581da3a89a73cca4159438f29ed", "targetDigest": "ccc9c0c029bc15fcce99a3983cf0c9d461f480d704227fb814396958cb8ffa7a" }, + "skills/sharpen/SKILL.md": { + "upstream": "pstack", + "source": "skills/sharpen/SKILL.md", + "sourceDigest": "563dfc460c605d311e650726288e2f1cbd61eab817db178d6e7e29643e07e7a2", + "targetDigest": "c804fef77be5157c6a47125e931ff0f914988316cad544607e837cd805d68f93" + }, "skills/show-me-your-work/references/decision-log-template.tsv": { "upstream": "pstack", "source": "skills/show-me-your-work/references/decision-log-template.tsv", @@ -611,13 +635,85 @@ "skills/show-me-your-work/SKILL.md": { "upstream": "pstack", "source": "skills/show-me-your-work/SKILL.md", - "sourceDigest": "bcff5f7f9fd23f92c12b251f9cd6b947e9485e1055cfacb6fdd9cae0789d7550", + "sourceDigest": "920a8217cbb62f92a2222fb81fc9eab17ecd554a0277e444a95a927195139bd1", "targetDigest": "1464cc0dbe44a03df3250456260de8fdfa8535b80e10abaaf9e5b121637919cd" }, + "skills/statechart/scripts/chart-to-tla.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/chart-to-tla.mjs", + "sourceDigest": "9f794e273d0729ae874d68a67bfa1df4ebddc12420b7ab5081fdf58dceb729e4", + "targetDigest": "9f794e273d0729ae874d68a67bfa1df4ebddc12420b7ab5081fdf58dceb729e4" + }, + "skills/statechart/scripts/compare-charts.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/compare-charts.mjs", + "sourceDigest": "12f4dc4700093156978ef1785b92739c8cf1e8375b8d13201fcdc2006a20c43b", + "targetDigest": "12f4dc4700093156978ef1785b92739c8cf1e8375b8d13201fcdc2006a20c43b" + }, + "skills/statechart/scripts/lib.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/lib.mjs", + "sourceDigest": "47731d9dba3fd831bb81b739af31f2dd80b7a25dd6fa6786ee3c0d531e1ab781", + "targetDigest": "47731d9dba3fd831bb81b739af31f2dd80b7a25dd6fa6786ee3c0d531e1ab781" + }, + "skills/statechart/scripts/model/OutputFolder.cfg": { + "upstream": "pstack", + "source": "skills/statechart/scripts/model/OutputFolder.cfg", + "sourceDigest": "08529388d6b9cc4519593ae2a055af27bac005c99fa1e21cd6703c706e861f23", + "targetDigest": "8b9662ab7d51de9d54f84abab02888822da9c01ce4f1b86a6453a8a513773995" + }, + "skills/statechart/scripts/model/OutputFolder.map.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/model/OutputFolder.map.mjs", + "sourceDigest": "33f2ef1c2ba397573206640843fffbc228d066e53fe5ced779d82f99600f3fd8", + "targetDigest": "33f2ef1c2ba397573206640843fffbc228d066e53fe5ced779d82f99600f3fd8" + }, + "skills/statechart/scripts/model/OutputFolder.tla": { + "upstream": "pstack", + "source": "skills/statechart/scripts/model/OutputFolder.tla", + "sourceDigest": "6bed8d69f3ab817865e7271986c47adab5ed170c127aa7b83017b71f5fd30640", + "targetDigest": "75bef6811d9a8e56bd702fd8d782e52101918c8dc05c6cc9dc43d02b9fe1b56f" + }, + "skills/statechart/scripts/statechart.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/statechart.mjs", + "sourceDigest": "d352ca68fb05cef8673ce1b86ea516cc619ff6c1f4faea70233de59bcdf7a9b7", + "targetDigest": "d352ca68fb05cef8673ce1b86ea516cc619ff6c1f4faea70233de59bcdf7a9b7" + }, + "skills/statechart/scripts/test/statechart.test.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/test/statechart.test.mjs", + "sourceDigest": "494119bb923d25876d4984c6831a6ffcbdfd697c5f834cd6e61121ca5530b83c", + "targetDigest": "494119bb923d25876d4984c6831a6ffcbdfd697c5f834cd6e61121ca5530b83c" + }, + "skills/statechart/scripts/test/sync.mmd": { + "upstream": "pstack", + "source": "skills/statechart/scripts/test/sync.mmd", + "sourceDigest": "7a6167157bd1482f31a9e123547e7146d5928dafd3a05b00fc40a21438f28b00", + "targetDigest": "cb1f5387058a0f79d6187eb217690588a2e2d384637b4d53cbc4bb050c570757" + }, + "skills/statechart/scripts/test/upload.mmd": { + "upstream": "pstack", + "source": "skills/statechart/scripts/test/upload.mmd", + "sourceDigest": "99b100520e33ccc9f2343cc0b7edcd135b9351219633ff59f3560c79497081e6", + "targetDigest": "bbc1fa70e26cbcbcc7cde5d8fb7eda26df34b6d973053bf13c40b18527ff0e76" + }, + "skills/statechart/scripts/tlc-to-chart.mjs": { + "upstream": "pstack", + "source": "skills/statechart/scripts/tlc-to-chart.mjs", + "sourceDigest": "ce41082fbc7aac05f135ccfd1e80705c60e5b38364187dd22fb1e27fdb017cd6", + "targetDigest": "ce41082fbc7aac05f135ccfd1e80705c60e5b38364187dd22fb1e27fdb017cd6" + }, + "skills/statechart/SKILL.md": { + "upstream": "pstack", + "source": "skills/statechart/SKILL.md", + "sourceDigest": "ad01133b5578a6e1949edeee7ccd49f918c0a37c980e9b8adc1ca27c28fbd959", + "targetDigest": "27022c33d9b423d51113aac119519bf5cf87c7c30c4cc6a8109525dfbba6a2f8" + }, "skills/swarm/SKILL.md": { "upstream": "pstack", "source": "skills/swarm/SKILL.md", - "sourceDigest": "bdcee4168e22428036e93d7cdeec3f391dbc4132f927f6db2540328c22db0273", + "sourceDigest": "031657cb81e490f9aed5c75d6ff2a4fda35ccafbaf72be604e59481cc4080cdc", "targetDigest": "93f49f135adb52278200670929eef76f03edb6995f5f3c074a690441a38d4e95" }, "skills/tdd/SKILL.md": { @@ -641,19 +737,19 @@ "skills/typescript-best-practices/references/patterns.md": { "upstream": "pstack", "source": "skills/typescript-best-practices/references/patterns.md", - "sourceDigest": "faa1362055374452efccac1b629677c61acfb0bcc6c4e3311836b5715638ee18", + "sourceDigest": "12e70c9419a8b3f5d6a1ab80e272c1f00a43ac0b3bbf72d68199033570e5de77", "targetDigest": "d1b52ded45e285c9a0b7d720dbe896cecab845731e280906dc05128c57e9c1f9" }, "skills/typescript-best-practices/SKILL.md": { "upstream": "pstack", "source": "skills/typescript-best-practices/SKILL.md", - "sourceDigest": "28f9e61710e205f6f3c5476f333483cff0e988f6a5b639e2faad536344b40c5e", + "sourceDigest": "ec1dac086d3708e5940d45d250c30a37dd953b4e3cb2ca0692f5851d7a039e27", "targetDigest": "02b41b2cbb0ac43940b8f4df8b54c55f1aac0a68890a9379f9b006b2a8f1f4a0" }, "skills/unslop/SKILL.md": { "upstream": "pstack", "source": "skills/unslop/SKILL.md", - "sourceDigest": "3126b9e2cf46022f5e0c332e84be5e17a9730669cb3d6ac0a7e5e7e94b1e24fa", + "sourceDigest": "ccab208e315ce05e2fab8eeee2f7879f1289b5c6844840b2d307adf31c79144f", "targetDigest": "290a10a90e52f080fdf0f225f043088990b5cd8d8b8307c800a91cfeb1568aa4" }, "skills/why/references/epistemics.md": { @@ -677,7 +773,7 @@ "skills/why/references/sources/code-archaeology.md": { "upstream": "pstack", "source": "skills/why/references/sources/code-archaeology.md", - "sourceDigest": "f6b5c4c3dd4ddc31da446bbbe4d88806e2201f15a76b97b3e44ce25c5862e321", + "sourceDigest": "a4a3970e9db448054aed676f3f7feeee667b4b69f0bd57c8fd72b751be687cc7", "targetDigest": "8c14aa382f0de40829b2668d43e3f54814480bb93a486c9282ada4003f6b9dbd" }, "skills/why/references/sources/databricks.md": { @@ -695,7 +791,7 @@ "skills/why/references/sources/incident-postmortem.md": { "upstream": "pstack", "source": "skills/why/references/sources/incident-postmortem.md", - "sourceDigest": "a899470f05f86029630a13a2696491cc708ca7b56a6e822a69ff6e954d6ff8cf", + "sourceDigest": "bf2661f1a81c37700884a45b77d6289be0c802b065febced31990e6564510839", "targetDigest": "a899470f05f86029630a13a2696491cc708ca7b56a6e822a69ff6e954d6ff8cf" }, "skills/why/references/sources/linear.md": { @@ -713,13 +809,13 @@ "skills/why/references/sources/sentry.md": { "upstream": "pstack", "source": "skills/why/references/sources/sentry.md", - "sourceDigest": "e0ed11e474c3d248bc5ac16d37ca1439b2db0253a3413252194556643cf8328a", + "sourceDigest": "2d4f83048eac1df530b399e2aed75dd7c9c18577b7665420aaeb96d0ceb0b702", "targetDigest": "6f292bc4fb1548e956f95e1900b9481abf5134254419f1cd9edec95428c82d73" }, "skills/why/references/sources/slack.md": { "upstream": "pstack", "source": "skills/why/references/sources/slack.md", - "sourceDigest": "58fee61d024b3700b407e7acbed367944720ff05adfddb6954d36c88368f3ba7", + "sourceDigest": "3bc6309f5c7b26ace3b919dbd7f7b048861dd2020f910fe24f656cc74b3b661c", "targetDigest": "58fee61d024b3700b407e7acbed367944720ff05adfddb6954d36c88368f3ba7" }, "skills/why/references/synthesizer-prompt.md": { @@ -731,7 +827,7 @@ "skills/why/SKILL.md": { "upstream": "pstack", "source": "skills/why/SKILL.md", - "sourceDigest": "fa1d1e9dec14b15381c1ccccad74044d6b6ea3b4af56821f59ba2d8e59b07545", + "sourceDigest": "144d42e7712723d763e607bc0a969e5f1cdf83ec7228519d87677cc534571bed", "targetDigest": "873aedc86d1321093e9a195ac8bff1783241b309a1d33808ab74f181c4b8f22d" }, "skills/writing-for-agents/SKILL-MECHANICS.md": { @@ -763,7 +859,7 @@ "tools/meta-mode/check-plan.mjs": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/check-plan.mjs", - "sourceDigest": "4f9432d9989880b6afc2bdda503d5b12a9d07d357ecf31936b7ab5e83bc80523", + "sourceDigest": "e58c3d871b09d477be00628d69dd23ffa6c2e3a1728f5b867a841baa8e90278b", "targetDigest": "093d6a6d987aa6e63560e45840eef4379a472278e0a7e5ad3542025891341809", "reason": "Runtime tools are packaged separately from portable skill instructions." }, @@ -772,6 +868,13 @@ "reason": "Local portability guidance or implementation; review alongside its owning skill.", "targetDigest": "cd81a473955c5cf2f3481654b2c993d28313263cb1cecf09856954b07681fe6b" }, + "tools/meta-mode/check-plan.test.ts": { + "upstream": "pstack", + "source": "skills/poteto-mode/scripts/check-plan.test.ts", + "sourceDigest": "c80f3ea043b81f219b92c4c8a3af6bdd4f8d32ea596ec15b5b9e0252645f2ae8", + "targetDigest": "c80f3ea043b81f219b92c4c8a3af6bdd4f8d32ea596ec15b5b9e0252645f2ae8", + "reason": "Runtime tools are packaged separately from portable skill instructions." + }, "tools/meta-mode/check-playbooks.mjs": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", @@ -780,28 +883,28 @@ "tools/meta-mode/orch/orch.test.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/orch.test.ts", - "sourceDigest": "773153ac74de6eb15f58d2682f487f5f30672d7efe3ef1d24a131aa9129d279b", + "sourceDigest": "5e80b416bdcd68e4067690ce857345852fd2e0db3dcf3e25607999cd55b963d2", "targetDigest": "821c2cd328e441590bb236733ca48a6f889e13c7c49447c5a0c170ac999af5a5", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/orch/orch.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/orch.ts", - "sourceDigest": "f091687df627a0b75fabd54af58945a9cd6c7039ef0622012ac9ed60cd8ec434", + "sourceDigest": "47c92c57e8276616d8de94665a126bd832e748036afa8d0ffdbacb7946decb5c", "targetDigest": "b820c51d3ed13fa62ef5fc969aacf2b0b3c7f471a518f6159d3c3a530a363cd7", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/orch/store.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/store.ts", - "sourceDigest": "0eae7cf69282e827b1227166f2e32cee3560f2f3e65b3f1f2faf6fb83d3b4c5b", + "sourceDigest": "558cfda533e1ebe7c8d7842604f07e0be7618759cf129b4c2397a8e29516ecea", "targetDigest": "c879408105d96814345f9e15f9a909505fec1374036c01e58820a55374a194bb", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/package.json": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/package.json", - "sourceDigest": "d1f815091209d49763775cc188e8e06ff32d5fec648bffd2272bc25a165e87c3", + "sourceDigest": "fd511a0c01f522e0c781ddd8eccea0e91697a0856c6e6a3261de78a6a0287c72", "targetDigest": "c7ac7fd69bb925415cdc15cd636567304ee40f8d665986e45ed8c0ee658f6caf", "reason": "Runtime tools are packaged separately from portable skill instructions." }, @@ -847,35 +950,35 @@ "tools/meta-mode/watch-pr/fakes.test-helper.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/watch-pr/fakes.test-helper.ts", - "sourceDigest": "e53e31b7f03068063f53a99a3591b052aaed66e4e2b196b3a364980f60324ab1", + "sourceDigest": "cf0c5ee61b4a73444c1b775cfb6c81a40d312e055dd21a1f5eae6276e54de13f", "targetDigest": "e53e31b7f03068063f53a99a3591b052aaed66e4e2b196b3a364980f60324ab1", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/watch-pr/github.test.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/watch-pr/github.test.ts", - "sourceDigest": "0cbce011050522938523e36bb42a260e92b812dcd2076930fa681672ad316b24", + "sourceDigest": "70c9f03d8738228763846f2abdd8c7de7be68dba3035041482386a69cbf5639b", "targetDigest": "42e2c624bb7290761c05cb24a4b7e616c6f3c3d8af46aba5cf30ead38023786e", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/watch-pr/github.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/watch-pr/github.ts", - "sourceDigest": "d1bbbafbe8e2c703c84da2029de7f2c4011412512f8cf65a42100f46bcb92c8a", + "sourceDigest": "a605b8531468dbe4c07b93663c0993a9c11dc7e9179adde970f5c740396451d9", "targetDigest": "8f44dff3af48e9872cc2f3a20cf0a6128a1f4dd3054afeabee054e51776f7ec1", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/watch-pr/policy.test.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/watch-pr/policy.test.ts", - "sourceDigest": "a9fedb25feefd1f57ef6a662f80c99018690170a3a8070773a08eaf694d1d3d9", + "sourceDigest": "146acabcfee9b6fa3342358eb0a9504aa65fd081bbc15b10adf9c1c5485067d2", "targetDigest": "a9fedb25feefd1f57ef6a662f80c99018690170a3a8070773a08eaf694d1d3d9", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/watch-pr/policy.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/watch-pr/policy.ts", - "sourceDigest": "68d240505852cf23c9b2dea463342e9e61243f486b3cc86c735d140f9ee7be49", + "sourceDigest": "f2a3e31223d05edc05e5473cc3f8f3cb9bd20c202c30c6abe7f21f0f7aee8fbb", "targetDigest": "68d240505852cf23c9b2dea463342e9e61243f486b3cc86c735d140f9ee7be49", "reason": "Runtime tools are packaged separately from portable skill instructions." }, @@ -903,7 +1006,7 @@ "tools/meta-mode/watch-pr/types.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/watch-pr/types.ts", - "sourceDigest": "64ba6221e3bfe61bbb292337d199cdec6309455477280efb060203dba34e0e91", + "sourceDigest": "0cac435294dc85ce1ba755e8e67f6c5ab163dfa252f2722d6e3a2d68c58fec59", "targetDigest": "979de5c965f10a853bac7847e043fff613e7884a93c16fc95a39766177588ef7", "reason": "Runtime tools are packaged separately from portable skill instructions." }, @@ -922,7 +1025,7 @@ "tools/meta-mode/worktree-audit.sh": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/worktree-audit.sh", - "sourceDigest": "95b8530f3c0ccada8cabc4b0727c052fbc85c141531375aaace65c427af8aecc", + "sourceDigest": "e0a94e5e5b1ff60638916e440600e15851f7b79bc1a1d1f602fde05d465e2ec0", "targetDigest": "6a87d48f5664868537d722e059d4a3807a1283cc0382ec01e7d5f764098dbdbf", "reason": "Runtime tools are packaged separately from portable skill instructions." } @@ -955,13 +1058,13 @@ "pstack:skills/poteto-help/references/recipes.md": { "upstream": "pstack", "source": "skills/poteto-help/references/recipes.md", - "sourceDigest": "1872323419a3e5c7783b1933f7a180ca26c25a6e1f4ec83bd65e091ebbfb812b", + "sourceDigest": "f2d573014f72f7c798b1998b45f4360a8a83e7b8026c4ed9074e56c5a9d49338", "reason": "Links to repository pages the default installer does not ship. The local mstack-help skill replaces it and points at public URLs for those pages." }, "pstack:skills/poteto-help/SKILL.md": { "upstream": "pstack", "source": "skills/poteto-help/SKILL.md", - "sourceDigest": "3fe669c7c3b8f52c18b7c68ecce01c8691e844b9d2b41a8a6ff6432341451986", + "sourceDigest": "7022e2cd0d65d5964151444c4efa82a34792a6644210a0ba60d7d6cfa69c72b8", "reason": "Links to repository pages the default installer does not ship. The local mstack-help skill replaces it and points at public URLs for those pages." } } diff --git a/profiles/skills.json b/profiles/skills.json index ed715bf..466b403 100644 --- a/profiles/skills.json +++ b/profiles/skills.json @@ -8,12 +8,15 @@ "bro", "codebase-design", "correct", + "create-formal-verification-skill", "create-verification-skill", "diagnosing-bugs", "figure-it-out", "how", + "in-your-own-words", "interrogate", "ketchup", + "maintain-formal-verification-skill", "maintain-verification-skill", "make-bot-ui", "meta-mode", @@ -46,7 +49,9 @@ "recall", "reflect", "setup-mstack", + "sharpen", "show-me-your-work", + "statechart", "swarm", "tdd", "teach", diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index 2103e40..79d07b2 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -1,17 +1,17 @@ { "source": "https://github.com/cursor/plugins/tree/main/pstack", - "commit": "11713c683fe4add4fd403ea880580abbc9e0875f", + "commit": "eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10", "canonicalSkills": { "architect": { "source": "76ca68a7e061c68a9d387cb60a4c88eeca7f7aa4f472b6053e42e2ec19db6597", "target": "76c299297a79236fa519d401716a6ad1845ecc1a3f4fdff2aa052309261651aa" }, "arena": { - "source": "39ea26609b17d925e97cea760f03ba60adea45f4f7232af7a701f8ebacd2b569", + "source": "106aab252770da0d90ccc5f3ca5ec50314a9e2e68458c4adce69500aa7eef4cd", "target": "f100383e190b806567015d2cbbaf90b9a1b0151ae60b08aa6185fc6eca3c9552" }, "automate-me": { - "source": "c80a6024fa6bebfa6875975c74293415fa6f729087377998751dc1343c865e75", + "source": "9784d113d31bbd7607604755e85ae18519d3ed02155619abcefd382a4ecb1f19", "target": "6ab6c4c4a4b50a7cb182149408bd1758229d4386fbb8b1a66cf6927847b02c27" }, "benchmark-checklist": { @@ -27,47 +27,59 @@ "target": "8b822d47b9299627f5e4fe42684a031105792c8531443da1ae3423adfa2de96f" }, "correct": { - "source": "bf482bacc972b3f124a4af8bf9364b4b3e280cb5845e44c326ea4f4565e5c904", + "source": "e347045d00c967a0b831ef883cc6ac79d21329aa10b5968637df95de7febf0ba", "target": "b7af9686e96f242ddd7d0c12731dd8c4347f4eff1c6d404dc87403d87076d492" }, + "create-formal-verification-skill": { + "source": "6c934cd15a9583533ff9b7a9cad62775d7e80402d26d3ba7f74f79ea21a1d8eb", + "target": "e1a3ccafaf3383c897e87a2b6cb1f93033ae42ce9555fd8e87947f24648d67c7" + }, "create-verification-skill": { - "source": "644f2551403c1bca01a2855b34611b6e7be0ce0dc5b204514c376c0f6a6e6ac4", + "source": "d353973547bf8ad8977eca645f4cd4feaed0d32f4b43d1448f906b0a76c4d694", "target": "eca219cac2808714d6aa3090fd3065d1da6a579d432f277aef334f1ca0dfe770" }, "figure-it-out": { - "source": "d9ed31f08dcd8b80693dcc0da6c5d8bdaddacb341c2ff4aaabc189fe47561e99", + "source": "86d614158b16ed81737cdad5b081dd6f0b19ad8ed1bdf1e79d0c5a085348f10b", "target": "37c25a3c0b354da74e33a2a291fc21e62140304eee2447ed73400bc2759402ec" }, "how": { - "source": "1ea39010bbb68f9dccd669ac516f5e90b5f3ceaa0b00f4f59c7d4b7f9faf50b8", + "source": "bdfe609597c5be0b2ef69111475d08db7e8b71c156e6976bbfcb36a7fcac432f", "target": "a3e2acb068e126bad1ddadb4b16bb14154d5687fffc8ce2fe8209660b83c2e4b" }, + "in-your-own-words": { + "source": "fa8ba74e27d7be371dc270ec78bbf816411477d0dfab185356c861ac8dd413ad", + "target": "292b1f5b008a78be49830f02f8bc04be7bf0df50e66d0143138f244f2b47a38a" + }, "interrogate": { - "source": "facea53a9a920ed50fa5a8ea10fea51ad1d81effbb2a7442496db51e468b8415", + "source": "ada0adb5ad7fea08246a992bbc65e7b0c3338695a09dcdd693ce72288af28698", "target": "ab2508809da00eac47444b102cd7d11730f15b2476cd6cd947e330cc08814e78" }, "ketchup": { - "source": "f460a861cf2179808abc24b5588c94709f8cc3e58d4da2e44baf89556f53b587", + "source": "f2923ffd14f6fc778f75a47aaed1f317fb5fd00b5701ca769d6446c74106dead", "target": "451887dc45227e0e1abd9abfb043e0bc231ff0b246a8ed48b05786306ada123a" }, + "maintain-formal-verification-skill": { + "source": "0c96f54293542d3818b68754562b338073958781a9684454611dca37592e2d2c", + "target": "72ada37e2478af4c3902ed7b2e858e930cbbf06625e0001cbdc8c5612965964f" + }, "maintain-verification-skill": { "source": "515c0eaa054b3f6be1b1fb06f2c2f173c80fddb58bbcac57576f89c479bc68e8", "target": "b236ab1a439ae067d41d0fc191517992da1130d7935d3c8fa5084def26512296" }, "make-bot-ui": { - "source": "3624c8ef16cf2e48deb2a4049e72c6962a9431c8d6c6af90e7bd7ea0375224ec", + "source": "ad19d0bc6764287ec08c2521701974e7dac941f72e4d0fe81a24f4614494586f", "target": "2f04984c34d64ad8119eac43f24298d480528b14a9876d672aee0c942aa0c2da" }, - "no-comments": { - "source": "5c5b0882297d704c3a9720c52b7a793c68b013eaf717989f0945624efdfe2b05", - "target": "f5bc3892fd057751d5928cffbebabbcde58804021a520bf67fac6b550d3399f5" - }, "meta-mode": { - "source": "990d7bd0fdbe817d39e646d7dfb7c3aa038cb7e73c42369a17afe61bfc9e06e2", + "source": "32934b8b1047c59261d0514ad7165a162ad71959007251a9effa19afb31e75c8", "target": "1bd904d7163d624d390c09e7bd1079bb5dcf3435828ee72fa34882e06f347c52" }, + "no-comments": { + "source": "9c430661c2b95596373e35abbb42a813980ae8318156d1903ca1a097dea4f894", + "target": "f5bc3892fd057751d5928cffbebabbcde58804021a520bf67fac6b550d3399f5" + }, "principle-attack-the-premise": { - "source": "c87bd7536f772f8a403ad8155535ee17a1bbec9a353156693eb3bb1520c87ae3", + "source": "6a932884daad4aa189e9b5d1665226bbc75621d4d2a398fd4003bebd382f4599", "target": "58c2998cbe0318b648d2f78c080003c5d3685a0ede2a98e56bc29e2fdfa7417a" }, "principle-boundary-discipline": { @@ -79,11 +91,11 @@ "target": "b49623e8e41c072ce14e6479a0a93cb932cd1a320642334cf0ac8f9e7cf31ded" }, "principle-encode-lessons-in-structure": { - "source": "ad12c26209770c52f632d9480c048ac5334ce8e5d6297f83ade59652a56cb3ef", + "source": "228963799189f481827d6f28065f278bc2008ffa37030b1234cbd5814d36b9e9", "target": "e66d95bd954a1e8ba298bc451ba1e9190c758f6d77c240540bedf8f28c895694" }, "principle-exhaust-the-design-space": { - "source": "8583310d7297b7823d75494faa57b003564d436807febecdecbd8321abe5cf6e", + "source": "2de83d99f93f60ec6313455b0bc9aa9f0bfb93dd59f414f403691322db00d4db", "target": "67e7e29ab4f484bfddcac55ece4d70f6efc25ccbf91de7de31fcec385f9f059e" }, "principle-experience-first": { @@ -95,7 +107,7 @@ "target": "0a5cb3579a30e4da9d1635f5ceb11f773e551ec8626ac9c38b5db3985d3ac26b" }, "principle-fix-root-causes": { - "source": "ba4cd38da1dcc8fe5432feb64457cba95594736a9b21b2ebf8f8c52aee068707", + "source": "8df3c8c3fe57eb0055a5c8efab8ab144557fdf301da70b1cdc77a3a3ef3f3052", "target": "2f48a508674c8b9f3339aba95929079af54fb7fbd367b4eb45175388f1e170f9" }, "principle-foundational-thinking": { @@ -103,19 +115,19 @@ "target": "e69eea824d81c4bb32cbcdb26513bf964882daeeb647bb99210978e86bec1a49" }, "principle-guard-the-context-window": { - "source": "d2cef147862576e3d709b7afc149d85196245e8338b9c7eef337c95fba412b3e", + "source": "af0b9dd70b43628ce4a59a4d332e9d9ad055084e460f430e7a757495c627e81a", "target": "7a0a9540d4c0e8053ace79628d0a493444a82d1bbe1d8e94c8e561606c3d2912" }, "principle-laziness-protocol": { - "source": "9006579e9e3f33955d17569733db688ae65f22577dc3b21803d219a5a8445aff", + "source": "1be6e7a435f26a75a76eb848b9c3d611b9977d0f5822361753244da25ca3ca45", "target": "f45159ad7ea5f01a08d364897307f69ca18fa57b87d6ed6b89ca222a2388bf7d" }, "principle-make-operations-idempotent": { - "source": "540738217c3da7bf513b9886924ad6a2bc77e58be867f5bd2e1831c74bf2ce70", + "source": "7aa9327cf1f1aa057e95d47a0a78bd88a8f9d556b608866fee939d455a53a019", "target": "5cf54da6cc34714fbe97e45fd61a4678662d087d7f6f6dc81cace3f34041f541" }, "principle-migrate-callers-then-delete-legacy-apis": { - "source": "09978915a4a11990cf0a9a02d9c08cc59e0b7a04c5c24b83aa560fbe4146f75b", + "source": "580ad9bb1a0994ecb924321ba49efc38a2f85c64e22c76fb133f57a78272a2e6", "target": "388e9cbc9e52bd72d9044d7e37126bd3355c4e8f1188447e2a6850fe4fc549d7" }, "principle-minimize-reader-load": { @@ -155,31 +167,39 @@ "target": "65fa4e1a2a630040eb0abbcfeaf0e82241fd968443021b535f94b4b6d4b7aa0f" }, "principle-test-behavior-not-implementation": { - "source": "b2b73ba42920f9c3174ecdf808ec6e4591b3a9266ada9a05814f386004266d3a", + "source": "f7223698623b6a26fea49c751e466b38e6691b1f32ce841d3983e7ffbe2b74a6", "target": "c6104107aa4902342d36eb5c6b7f99862eadff5ba27f367cde011ccdde4d8d17" }, "principle-type-system-discipline": { - "source": "2e492cef34bb25a20b9115df61f9ef3a9837e3fda144ffda8079e5f45a1b72e8", + "source": "5cdaf79785e3c12b2592b9469ee8c272143eb86ab7a18d8382197cea5fd30106", "target": "9397a9ce6e26e54d0e6e9f81e488f626f3daefd6903509536fbdfb0d021a274f" }, "recall": { - "source": "d265b0e5b6dc5b89c0407f3387d24462c988e1990a37d5359735137908128c5a", + "source": "f79a2cfa5d73ba70e4774bf26259134ef609bf7589ecee06491781718d65ee60", "target": "be67c9e064a2b6d2ec52e915c19f6507f9f389a112e9961eb93b3c793b565e70" }, "reflect": { - "source": "15c32130fc17fd155852d5e95e81110f390cb5266c1cdd9db59b837d1e31984c", + "source": "0409fc5d2b5485f0790ea17816dc3da9e66ce9e16b7a58a8587f82e73986c26a", "target": "5478382b85614482fdf8799c9b0113d0c12c6595ee1003aa62c91f6288b91089" }, "setup-mstack": { - "source": "0b6497df7bbb807406afe14c8967ce07bc0e0738e9a341c512d264aa94cc5aa8", + "source": "ef40d7cff8e0720dcb01e18f5ef44865ae1caaf0466045cecff8fb2cc9c5fc42", "target": "ccc9c0c029bc15fcce99a3983cf0c9d461f480d704227fb814396958cb8ffa7a" }, + "sharpen": { + "source": "563dfc460c605d311e650726288e2f1cbd61eab817db178d6e7e29643e07e7a2", + "target": "c804fef77be5157c6a47125e931ff0f914988316cad544607e837cd805d68f93" + }, "show-me-your-work": { - "source": "bcff5f7f9fd23f92c12b251f9cd6b947e9485e1055cfacb6fdd9cae0789d7550", + "source": "a3634a8b68c8f68c73c48bd84f926f09b03d6e8a29fe8984ad821b8a83202cbf", "target": "1464cc0dbe44a03df3250456260de8fdfa8535b80e10abaaf9e5b121637919cd" }, + "statechart": { + "source": "ad01133b5578a6e1949edeee7ccd49f918c0a37c980e9b8adc1ca27c28fbd959", + "target": "27022c33d9b423d51113aac119519bf5cf87c7c30c4cc6a8109525dfbba6a2f8" + }, "swarm": { - "source": "f1d3a129f80f2e54d134f0216e5d7f6dd5043b3da245e2af38a94946f9f38153", + "source": "c02101c032c34a3013407506528421e778845b0d289fcd5b4e805bb036783d12", "target": "93f49f135adb52278200670929eef76f03edb6995f5f3c074a690441a38d4e95" }, "tdd": { @@ -195,15 +215,15 @@ "target": "b83d616480d74381acdc5accb65a409c01095b71abc7dc02b6cadc496138dc81" }, "typescript-best-practices": { - "source": "28f9e61710e205f6f3c5476f333483cff0e988f6a5b639e2faad536344b40c5e", + "source": "ec1dac086d3708e5940d45d250c30a37dd953b4e3cb2ca0692f5851d7a039e27", "target": "02b41b2cbb0ac43940b8f4df8b54c55f1aac0a68890a9379f9b006b2a8f1f4a0" }, "unslop": { - "source": "3126b9e2cf46022f5e0c332e84be5e17a9730669cb3d6ac0a7e5e7e94b1e24fa", + "source": "ccab208e315ce05e2fab8eeee2f7879f1289b5c6844840b2d307adf31c79144f", "target": "290a10a90e52f080fdf0f225f043088990b5cd8d8b8307c800a91cfeb1568aa4" }, "why": { - "source": "844130c025ed6025e37bf64c160e9a1d3511c716d0dabe3a90ed297c0f7337ad", + "source": "4161a84bfbb0cfbf7666296a8ef5ff7ba827d398a5d5090baa0dc247d334be82", "target": "873aedc86d1321093e9a195ac8bff1783241b309a1d33808ab74f181c4b8f22d" } }, @@ -254,35 +274,35 @@ "reason": "Route native Benny verification and maintenance through the shared canonical contract and feature map." }, "docs/guide/01-setup.md": { - "source": "a6a9100fc9ace40b6725c7b819928551403a27364e3478ea451fac45eff3dd15", - "target": "a6a9100fc9ace40b6725c7b819928551403a27364e3478ea451fac45eff3dd15", + "source": "809266f58df5fd51c58a42fd17d26322e1e82924b283281b3094f0bec49dabac", + "target": "809266f58df5fd51c58a42fd17d26322e1e82924b283281b3094f0bec49dabac", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." }, "docs/guide/03-understand.md": { - "source": "c7dce3a9b1f1c0de8c00084453a3a18b69f0ad0c6616592b584e2d19add69ebd", - "target": "c7dce3a9b1f1c0de8c00084453a3a18b69f0ad0c6616592b584e2d19add69ebd", + "source": "8c4d7fcd4a7048652c48a3a4a953b1b78361f2a8b23f846f2e473f9f532f5a65", + "target": "8c4d7fcd4a7048652c48a3a4a953b1b78361f2a8b23f846f2e473f9f532f5a65", "reason": "Point session pickup at the portable project-local resume checkpoint." }, "docs/guide/06-verify-and-ship.md": { - "source": "e3a8259f2b2450692e43d607848f5a85a7cee894490452968391079e52a8ac50", - "target": "e3a8259f2b2450692e43d607848f5a85a7cee894490452968391079e52a8ac50", + "source": "8d7185d17cdff573a034e3f93449fcc8bb80180551ab2260d09bc84a8336afbe", + "target": "8d7185d17cdff573a034e3f93449fcc8bb80180551ab2260d09bc84a8336afbe", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." }, "docs/guide/07-overnight.md": { - "source": "b66d8726a888fa8ed2f1b488fc1ca14ac67eab892ff655878bd09bacdcdefabc", - "target": "b66d8726a888fa8ed2f1b488fc1ca14ac67eab892ff655878bd09bacdcdefabc", + "source": "ff75e792447369c8baedc46f2400dca7110e36ffa750bd78be56a4c4d6c28e7d", + "target": "ff75e792447369c8baedc46f2400dca7110e36ffa750bd78be56a4c4d6c28e7d", "reason": "Offer a host scheduler as an alternative to a session loop for unattended runs." }, "docs/guide/README.md": { - "source": "4b0d617c745e1ff33274bea1c1bc1274f3e23514455bec97affc9e9951f2350b", - "target": "4b0d617c745e1ff33274bea1c1bc1274f3e23514455bec97affc9e9951f2350b", + "source": "45345d9749cf4e08aaaafbc5230d23b2fed937d63a68201290714d775765f5c0", + "target": "45345d9749cf4e08aaaafbc5230d23b2fed937d63a68201290714d775765f5c0", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." } }, "artifacts": { "agents": { - "agents/comment-reviewer.md": "c0fd0383008da45fc78cfac17b9007d62c42f87ad1c8d5c2fb658b1fd01f7c82", - "agents/meta-agent.md": "51f3791434b848cf8881d5d3e1cfcde502cf92f694c1d8748a65ca9603b33b3e" + "agents/comment-reviewer.md": "3e239e40b7b4b9beb2cf7f3ccc57f2388eb8b69bd1ba8ae888ef87ef6e0e2116", + "agents/meta-agent.md": "1556a869fe16181d8232f088441f9b6e4f1883b9749b73b2f89b8d927a3ca832" }, "automations": { "automations/benny/FOR_AGENTS.md": "9e86916edf7fdddf3325dac869d947d5cab767ee40f1b5eaf5d16e31f2a5322a", @@ -299,45 +319,46 @@ "automations/benny/templates/triage-automation-prompt.md": "e0bba594547834f013ec805915610eb597e7929efe3ffbe85ae00339ee16d474" }, "guide": { - "docs/guide/01-setup.md": "a6a9100fc9ace40b6725c7b819928551403a27364e3478ea451fac45eff3dd15", - "docs/guide/02-meta-mode.md": "681343e7135540ecd0125c83209e602c5e646534b89c2a6029cc29d4dc2455fd", - "docs/guide/03-understand.md": "c7dce3a9b1f1c0de8c00084453a3a18b69f0ad0c6616592b584e2d19add69ebd", - "docs/guide/04-design.md": "588228c2e8fd6f9c75c971c5801568cea53ed7a7e7ce84a6ac88947d96df021d", - "docs/guide/05-build-and-clean.md": "af91971988478ed0e53775d7f14f59e40ef7093b524d21df4f0b7917121b4f23", - "docs/guide/06-verify-and-ship.md": "e3a8259f2b2450692e43d607848f5a85a7cee894490452968391079e52a8ac50", - "docs/guide/07-overnight.md": "b66d8726a888fa8ed2f1b488fc1ca14ac67eab892ff655878bd09bacdcdefabc", - "docs/guide/08-principles.md": "cd8f9466703489b9eab7f5b54e8dce6101c212f045dfe7f519f0e7e2fbf60857", - "docs/guide/09-make-it-yours.md": "bd1031fe054c60f34f4b7450ababf827d48a2bd216ae874587c9f7511f193639", - "docs/guide/10-recipes-and-pitfalls.md": "99117ae3a94ae3047caf09efc5e2106c61179abd392f35007c7b82d1aff4dffe", + "docs/guide/01-setup.md": "809266f58df5fd51c58a42fd17d26322e1e82924b283281b3094f0bec49dabac", + "docs/guide/02-meta-mode.md": "954911b7cb0048791f2768867c456f827dcc64842085efc1dc2dc754af8f32f0", + "docs/guide/03-understand.md": "8c4d7fcd4a7048652c48a3a4a953b1b78361f2a8b23f846f2e473f9f532f5a65", + "docs/guide/04-design.md": "f9cbd9791d4ff9d2454614a6208fe1c645ea0205d2b41a5c91244e57b1e4e8c4", + "docs/guide/05-build-and-clean.md": "ea57e529de5b477e65c70f769a83b0f73a00fb848dc8fac3240b61aa756d7f81", + "docs/guide/06-verify-and-ship.md": "8d7185d17cdff573a034e3f93449fcc8bb80180551ab2260d09bc84a8336afbe", + "docs/guide/07-overnight.md": "ff75e792447369c8baedc46f2400dca7110e36ffa750bd78be56a4c4d6c28e7d", + "docs/guide/08-principles.md": "967788be974cc6fb1392969ed1d8e0d3c64eecd5a9f28f06bbe33238fe8146b9", + "docs/guide/09-make-it-yours.md": "419a09e996d0b836884d85931f8fdce75cfc2c5dcdca1c68d48dac9a83dc7df6", + "docs/guide/10-recipes-and-pitfalls.md": "79448254674a5f3c11eca6f2795350cff1922b4b2b500a13a9afcb3e5246ea1c", "docs/guide/images/design.jpg": "b8ed07427f6a0c37dd8ba97961fc0eb5071efeee6c4a230648bdb7547f6f2a8f", "docs/guide/images/overnight.jpg": "4b4405e4df8925c5b3abbe702f681af0f3b1f5f32271b599f95717f578f1a0c8", "docs/guide/images/recipes.jpg": "9fa2d51b476635712737795ddef81806efaa0086bb3bfa9f9f45695d9ab0f8e8", "docs/guide/images/router.jpg": "525843055a4efd2b10acc50a8652fd02b0834ea733a8a2f895c1eb89036017da", "docs/guide/images/understanding.jpg": "b60587e9a9bbe2550e93be9819cf0cd4acf77d90fac769a0c43d82bd8e34e279", "docs/guide/images/verification.jpg": "2a76b52007a1cd2866ba67fd47d7d9ef3e1118bb5a09c4665eee8c3f161bd09a", - "docs/guide/README.md": "4b0d617c745e1ff33274bea1c1bc1274f3e23514455bec97affc9e9951f2350b" + "docs/guide/README.md": "45345d9749cf4e08aaaafbc5230d23b2fed937d63a68201290714d775765f5c0" }, "meta-mode-tools": { "tools/meta-mode/bootstrap.ts": "d9d39f235da5512de6122f1ff28584b3a0120b170f5447a7a5868f7cf187d12a", "tools/meta-mode/bun.lock": "6413513594536785c3fd52b14934dd52d1ce179ad3ffe99d77535f5b637435a2", - "tools/meta-mode/check-plan.mjs": "4f9432d9989880b6afc2bdda503d5b12a9d07d357ecf31936b7ab5e83bc80523", - "tools/meta-mode/orch/orch.test.ts": "773153ac74de6eb15f58d2682f487f5f30672d7efe3ef1d24a131aa9129d279b", - "tools/meta-mode/orch/orch.ts": "f091687df627a0b75fabd54af58945a9cd6c7039ef0622012ac9ed60cd8ec434", - "tools/meta-mode/orch/store.ts": "0eae7cf69282e827b1227166f2e32cee3560f2f3e65b3f1f2faf6fb83d3b4c5b", - "tools/meta-mode/package.json": "c7ac7fd69bb925415cdc15cd636567304ee40f8d665986e45ed8c0ee658f6caf", + "tools/meta-mode/check-plan.mjs": "e58c3d871b09d477be00628d69dd23ffa6c2e3a1728f5b867a841baa8e90278b", + "tools/meta-mode/check-plan.test.ts": "c80f3ea043b81f219b92c4c8a3af6bdd4f8d32ea596ec15b5b9e0252645f2ae8", + "tools/meta-mode/orch/orch.test.ts": "5e80b416bdcd68e4067690ce857345852fd2e0db3dcf3e25607999cd55b963d2", + "tools/meta-mode/orch/orch.ts": "47c92c57e8276616d8de94665a126bd832e748036afa8d0ffdbacb7946decb5c", + "tools/meta-mode/orch/store.ts": "558cfda533e1ebe7c8d7842604f07e0be7618759cf129b4c2397a8e29516ecea", + "tools/meta-mode/package.json": "af7319c0b50784ac7887a3810f7dba7e1d448b138476bdb22508707fb4d49d9a", "tools/meta-mode/watch-pr/cli.test.ts": "a35592b4c6da96caf8d47987e9cd503fab6c7774ab87d11a43f24b4b5df2a9d1", "tools/meta-mode/watch-pr/cli.ts": "89c08863089181a232be782c4b2ed90a1552f44aeecb0dcb7aac2c623620e680", - "tools/meta-mode/watch-pr/fakes.test-helper.ts": "e53e31b7f03068063f53a99a3591b052aaed66e4e2b196b3a364980f60324ab1", - "tools/meta-mode/watch-pr/github.test.ts": "0cbce011050522938523e36bb42a260e92b812dcd2076930fa681672ad316b24", - "tools/meta-mode/watch-pr/github.ts": "d1bbbafbe8e2c703c84da2029de7f2c4011412512f8cf65a42100f46bcb92c8a", - "tools/meta-mode/watch-pr/policy.test.ts": "a9fedb25feefd1f57ef6a662f80c99018690170a3a8070773a08eaf694d1d3d9", - "tools/meta-mode/watch-pr/policy.ts": "68d240505852cf23c9b2dea463342e9e61243f486b3cc86c735d140f9ee7be49", + "tools/meta-mode/watch-pr/fakes.test-helper.ts": "cf0c5ee61b4a73444c1b775cfb6c81a40d312e055dd21a1f5eae6276e54de13f", + "tools/meta-mode/watch-pr/github.test.ts": "70c9f03d8738228763846f2abdd8c7de7be68dba3035041482386a69cbf5639b", + "tools/meta-mode/watch-pr/github.ts": "a605b8531468dbe4c07b93663c0993a9c11dc7e9179adde970f5c740396451d9", + "tools/meta-mode/watch-pr/policy.test.ts": "146acabcfee9b6fa3342358eb0a9504aa65fd081bbc15b10adf9c1c5485067d2", + "tools/meta-mode/watch-pr/policy.ts": "f2a3e31223d05edc05e5473cc3f8f3cb9bd20c202c30c6abe7f21f0f7aee8fbb", "tools/meta-mode/watch-pr/render.ts": "2b709e86628d5b8fdb61f860e60ababc342b0626c67576305442cf548283a604", "tools/meta-mode/watch-pr/tsconfig.json": "1ecccc72320b086c63ce3f73c959896fd747363e63553b3b1136296ea4069e45", "tools/meta-mode/watch-pr/types.compile.ts": "621af4c9a664fbf699a9737efccdc248abb416df8819c9dc5f824beae2b9b1d6", - "tools/meta-mode/watch-pr/types.ts": "64ba6221e3bfe61bbb292337d199cdec6309455477280efb060203dba34e0e91", + "tools/meta-mode/watch-pr/types.ts": "0cac435294dc85ce1ba755e8e67f6c5ab163dfa252f2722d6e3a2d68c58fec59", "tools/meta-mode/watch-pr/watch-pr": "a17825c7862730dd7c897c1ac076971c0f11d2f9ff8fe37c4a74ebc6db92d549", - "tools/meta-mode/worktree-audit.sh": "9f14b5f4f70a6ada93778e49701f854796b11776edccb9f73816ee2a8b8b623c" + "tools/meta-mode/worktree-audit.sh": "688dc3a6adf4964282039d69c6ca5a71d8996c05163e330c29d2916077450c8f" } } } diff --git a/profiles/upstreams.json b/profiles/upstreams.json index 89f784c..04be25e 100644 --- a/profiles/upstreams.json +++ b/profiles/upstreams.json @@ -1,7 +1,7 @@ { "pstack": { "repository": "https://github.com/cursor/plugins/tree/main/pstack", - "commit": "11713c683fe4add4fd403ea880580abbc9e0875f", + "commit": "eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10", "renames": { "ketchup": "ketchup", "poteto-mode": "meta-mode", diff --git a/skills/create-formal-verification-skill/SKILL.md b/skills/create-formal-verification-skill/SKILL.md new file mode 100644 index 0000000..a950e12 --- /dev/null +++ b/skills/create-formal-verification-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: create-formal-verification-skill +description: "Generate a project-local skill that formally verifies the code where a bug costs the most and tests reach the least, in any language, with whichever verification tool fits. Use for /create-formal-verification-skill, \"model-check this race\", \"prove this invariant\", or when concurrency, retries, state machines, parsers, or unsafe code need more than tests." +--- + +# Create a formal verification skill + +A test checks the cases that someone thought to write. Formal verification checks every case in a stated scope: every order of steps in a model, every input up to a bound, or every input. This skill finds the part of the repo where that matters most, verifies one property of it, and leaves a project-local skill that lets the next agent check a change against that property. Write that skill for an agent who reads it cold, mid-change, and has never seen your work. The user must be able to trust the result and act on it without learning the verification language. + +## Choose the target from the code + +Answer from the codebase, and ask the user only what you cannot observe. If the user named an area or a bug, start there. + +A good target is a promise the code makes, where a break harms users and tests rarely reach the case that breaks it. Choose the strongest such target in the repo, not the one that is easiest to verify. + +## Let the question choose the tool + +Pick the tool that answers the question at the lowest cost: a model checker, a proof assistant, an SMT-backed verifier, or another tool. Whether events can happen in a bad order usually suits a model checker over a small model of the design, and TLA+ is the default for that in any language. Whether the code itself behaves for every input, as with memory safety, overflow, or a data-structure invariant, usually suits a verifier that runs on the code. If no verifier runs on this language, say so, and test the real code with generated inputs, not a hand model of the function. A property that must hold at every size, where a bound is not enough, can need a proof. Prefer a tool that the repo's developers can install and run, and tell the user why you chose it. + +## What makes it good + +- **True to the code.** Every part of the model or proof points to code that a reviewer can open. What the verification treats as one indivisible step is one indivisible step in the code. Names come from this repo. +- **Able to fail.** A check that you have never seen fail proves nothing. Show each property failing on a broken variant of the code it guards, such as a removed check or a reordered step. +- **Findings stay findings.** When the current code breaks the property, report it with the counterexample in the code's terms. Do not weaken the property or the model until it passes, and do not change product code unless the user asks. +- **Honest about scope.** Say what is verified, under which bounds and assumptions, and what is left out. A pass covers the model or the bound, not the whole system. +- **Readable without the tool.** State the property and the harm in one plain sentence each. Tell a counterexample as a short sequence of steps in the code's words. +- **Cheap to rerun.** One command, a reproducible tool version, and a run short enough that the next agent will use it on every relevant change. +- **Kept in step with the code.** Record which files and functions each check covers, and the commit at which you last confirmed that the verification describes them. When the covered code changes after that commit, the next agent can see that the verification needs another look. + +## Generate the verify skill + +Write a project-local skill with frontmatter that names the area and says when to use it. Without frontmatter, the skill never registers. From the skill alone, the next agent must be able to tell whether a change touches the verified code, run the check, tell a real bug from a modeling error, and update the verification when a change is intended. Put the coverage record and the sync commit in the skill, and tell the next agent to move the sync commit forward only after it confirms that the verification describes the code at the new commit. Use this repo's real commands, paths, and nouns, and leave no placeholders. + +## Prove it before you hand off + +Follow the generated skill once, as the next agent would, on a scratch change that breaks the property in the real code. It must catch the break. Then drop the scratch change and confirm that the check is back where it started: passing, or failing only on the findings you will report. A verify skill that was never run is a draft. If the skill misses the break or a step does not run, fix the skill or the verification, not the scratch change. + +## Hand off + +Tell the user what you verified, which tool you used and why, the bounds, what the check found in plain words, how to run it, and what it does not cover. Suggest a second target only if you found a close one. Point the user at `/maintain-formal-verification-skill` for bringing the verification back in line after changes that did not recheck it. diff --git a/skills/in-your-own-words/SKILL.md b/skills/in-your-own-words/SKILL.md new file mode 100644 index 0000000..b3e10d8 --- /dev/null +++ b/skills/in-your-own-words/SKILL.md @@ -0,0 +1,6 @@ +--- +name: in-your-own-words +description: "Restate in your own words what you think the user's goals are and what problem they're trying to solve. Use for /in-your-own-words." +--- + +Restate in your own words what you think the user's goals are and what problem they're trying to solve. diff --git a/skills/maintain-formal-verification-skill/SKILL.md b/skills/maintain-formal-verification-skill/SKILL.md new file mode 100644 index 0000000..2d7e35b --- /dev/null +++ b/skills/maintain-formal-verification-skill/SKILL.md @@ -0,0 +1,57 @@ +--- +name: maintain-formal-verification-skill +description: "Bring a project's formal verification back in line with the code after the code changes: find the commits that touched verified code, update the model, harness, or properties to describe the code as it is now, rerun, and say whether each failure is drift in the verification or a bug in the code. Use for /maintain-formal-verification-skill, \"is the model still true to the code\", or after merges and refactors that touched verified code without rechecking it." +--- + +# Maintain a formal verification skill + +A formal check proves a property of what it describes. When the code changes and the verification does not, the check can keep passing while it describes code that no longer exists. That pass is the dangerous case, because it looks like a proof. This skill is the upkeep loop for a verify skill that `/create-formal-verification-skill` generated, or for any project-local skill that runs a model checker, prover, or code-level verifier against named code. The project-local skill handles a change that an agent makes while it uses that skill. This skill handles the changes that skipped it: merges, edits made without the skill, and drift that builds up across many commits. + +## Outcomes + +Give each check one outcome, and say which: + +- **in sync**: no change since the last sync alters what the check describes, and the check passes. +- **updated**: the code changed, the verification now describes the new code, and the check passes. +- **bug**: the verification describes the current code, and the code breaks the property. This is a finding about the code. It is not drift. +- **blocked**: the check could not be brought back in line or could not run. Say exactly why. + +A check can be both updated and bug: the update makes the verification describe the code again, and then the property fails on it. + +## Edit scope + +Edit only the verification: models, harnesses, property files, and the verify skill itself. Never edit product code during a run, unless the user asks you to. When a property fails on the current code, do not weaken the property or the model to make it pass. Report the failure. + +## Pass + +1. **Find the checks.** A verify skill has no fixed name, so find it by what it does: a project skill that runs a model checker, a prover, or another verifier against named code. Read each one you find, or the one the user named. For each check, collect the property, the code it covers, the bounds and assumptions, the command that runs it, and the commit where it was last confirmed to describe the code. If no verify skill exists, stop and point at `/create-formal-verification-skill`. If the skill does not record a sync commit, use the last commit that changed the verification files, and say that you did. + +2. **Run it as it is.** Run each check once before you change anything, and record the result. A pass here does not tell you that the code is correct. It tells you that the old description still holds. A failure here can be drift or a bug. Do not decide which until step 5. + +3. **Read every change to covered code.** Diff the covered files from the sync commit to the current commit, and read each commit message. Also look for code that moved: a covered function that is renamed, split, or moved to another file is still covered code. Also look for new code that touches the state that a check guards, such as a new function that writes the same fields. For each change, decide which of these it is: + - **No effect.** The change does not alter anything the verification describes, such as formatting, comments, or code paths that the verification does not cover. Check this against the model or harness, not against the commit message. + - **The verification no longer describes the code.** A step that was one indivisible action is now two, a branch was added or removed, a check moved, a new path reaches the guarded state, or a value now has a different range. The model or harness must change. + - **The promise changed on purpose.** The change alters what the code promises, and evidence in the change says that this is intended: the doc comment, the tests, the changelog, or the commit message. The property must change, and you must cite that evidence. + - **The promise may be broken.** The code changed, and no evidence says that the promise changed. Treat a failure that follows as a bug candidate. + +4. **Update from the code, not from the result.** Change the model or harness so that each changed part again points to code that a reviewer can open, and keep one indivisible step in the code as one step in the verification. Write the update from the code before you rerun, and do not shape it by what makes the check pass. Change a property only for a promise that changed on purpose. Write down the old wording, the new wording, and the evidence. Keep the bounds unless the change makes them wrong. If you must reduce a bound, say so in the report. + +5. **Rerun and judge each result.** + - **Pass:** the check can now pass on a description that has lost the ability to fail. Break the new code the way the property guards against, such as removing a new check or reordering a new step, and confirm that the check catches it. Then drop the break. + - **Fail:** replay the counterexample against the real code, step by step. If a step cannot happen in the code, the verification is wrong. Fix the verification, and say what was wrong. If every step can happen, it is a bug. Name the commit that introduced it, tell the counterexample as a sequence of steps in the code's own words, and state the harm in one sentence. If a cheap test or reproduction against the real code shows the bug, add it to the report as evidence. Do not commit it to the product code. + +6. **Update the verify skill.** Update the code that each check covers, the commands, the bounds, the assumptions, and the sync commit. Set the sync commit to the commit that you checked. Do not move the sync commit for a blocked check, and say so in the report. Then follow the verify skill once as the next agent would, so that you know its steps still run. + +## Report + +Tell the user, for each check: + +- the outcome, and the commits that you read; +- what changed in the verification and why, with a short reason for each change: the code changed shape, or the promise changed on purpose (with the evidence); +- the rerun result, and the break that you used to show the updated check can still fail; +- each bug, in plain words: the counterexample as steps in the code's words, the harm, and the commit that introduced it; +- what the verification still does not cover, including new code that it should cover and does not. + +Say outright which failures were drift in the verification and which were bugs in the code. For a check that passed before you updated it and fails after, say that the old pass was on a stale description. + +Put the verification updates in a change of their own, separate from any product fix. diff --git a/skills/sharpen/SKILL.md b/skills/sharpen/SKILL.md new file mode 100644 index 0000000..e0f456c --- /dev/null +++ b/skills/sharpen/SKILL.md @@ -0,0 +1,15 @@ +--- +name: sharpen +description: "Make a task deeper, not bigger, then rewrite the prompt. Use for /sharpen." +--- + +I want a deeper version of this task, not a bigger one. Before rewriting it, do four things: + +1. Say what a top professional in this field would do here that I'm not asking for. +2. Name the rawest material they would start from instead of what has already been digested for me. +3. Name the specific methods and tools they would use. +4. Say what, other than you or me, would show the result is right. + +Then rewrite my prompt to ask for exactly that. + +Leave choices that are mine to make open instead of deciding them. diff --git a/skills/statechart/SKILL.md b/skills/statechart/SKILL.md new file mode 100644 index 0000000..cc5aba0 --- /dev/null +++ b/skills/statechart/SKILL.md @@ -0,0 +1,35 @@ +--- +name: statechart +description: "Draw what an app's user sees as a Mermaid statechart from a TLA+ model. States are gray if they work, red if still wrong, and green if fixed. Each bug TLC finds is a numbered path. Use for /statechart, \"show the PM the bug\", or to turn an agreed chart into a TLA+ skeleton." +--- + +# Statechart + +The chart shows what an app's user sees. A PM reads it to decide whether that behavior is right. Label each state with what is on the screen, such as "Paying". Label each step with what the user does or sees happen, such as "tap Pay" or "the charge goes through". Keep variable and action names out of labels. The scripts call this user the reader, as in `readerActions` and reader steps. + +Run `node /scripts/statechart.mjs --help` for the commands and for how to read a chart. Work in a scratch folder outside the repo. The command writes its files next to its input. + +## Pick the direction + +Use `from-spec` when a TLA+ model exists or the code is concrete enough to model. Use `from-chart` when there is no model and the PM should agree on the behavior before anyone writes or changes the code. + +If the code already has a TLA+ spec, start from a copy of that spec. Add the variables the user sees, and keep the spec's existing invariants. To audit the code against that spec, make the copy model what the code does, and pass the spec unchanged as `--against`. In a new spec, tie every variable and action to code that a reviewer can open, and take its name from that code. Make each action one indivisible step in the code. + +## Spec to chart + +1. Choose the chart's states. When the PM cares about something the screen does not show, such as a second charge while the screen still says "Paying", name the state "Paying, charged twice". Start with few states, and split a state only when the split separates two bugs. +2. Write each rule the PM agrees to as one invariant, with the PM's sentence as its comment. For a rule that applies once everything settles, define `Settled == ~ENABLED System`, where `System` is the disjunction of the actions the user does not take. Report each broken rule as a bug. Never weaken a rule to make the chart pass. +3. Run `from-spec`, then fill in the mapping file it writes. Put the labels in `state` and `step`, and list the user's actions in `readerActions` by action name. When two bugs end on the same screen through different code paths, add `cause` and make it name the code path. To color the states by what the PM cares about, such as "Toast shown", return that group from `group`. The legend names the groups, and states that break a rule still get a red border. +4. If there is an open fix, model it from the code and pass that model as `--fixed`. +5. Render each chart with `--png` and look at every PNG. Check that each numbered path reads in order. If a label reads like code, fix `state` or `step` and run the command again. +6. Replay each bug's story against the real code, as a test or a live repro, one step per model action. If a bug does not reproduce, assume the model is wrong until you show otherwise. Fix the model, not the rule. + +## Chart to spec + +1. Draw the chart the PM agrees to. Use one region for each thing that changes independently, such as the connection and the sync badge. Add one `%% never` line per rule, with the PM's sentence. Draw "the app closes" as an edge to the state the user sees next. +2. Run `from-chart`. If a rule breaks, the agreed chart has a design bug. Take the bug to the PM. +3. Add the real code's guards and variables to the spec that `from-chart` wrote, and run `from-chart` again. For each difference it prints, ask the PM whether the code is wrong or the chart needs a correction. + +## Hand off + +Give the PM the PNG and the Mermaid source of each chart. Add one line per bug with the rule it breaks, its steps as the chart numbers them, whether it reproduced, and whether the open fix fixes it. Take every count you quote from `summary.json`. Attach `stories.md` and the command that regenerates everything. Say what the model leaves out. diff --git a/skills/statechart/scripts/chart-to-tla.mjs b/skills/statechart/scripts/chart-to-tla.mjs new file mode 100644 index 0000000..7dfa337 --- /dev/null +++ b/skills/statechart/scripts/chart-to-tla.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { parseMermaid } from "./lib.mjs"; + +const USAGE = "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"; +const HELP = `${USAGE} + +Writes a TLA+ spec, a TLC config, and a reader mapping from a Mermaid stateDiagram-v2. +statechart.mjs from-chart runs it, then checks each INVARIANT in NAME.cfg alone. + +Each region becomes one variable whose values are the region's states. Each transition label +becomes one action. Regions that share a label move together: every region that can take it does, +and the others stay. A "%% when" line after a transition is its guard: the region can take that +transition only while the other regions are in, or with "not" are not in, the states it names. An +unlabeled transition is its own action. Each +"%% never NAME: A & B -- sentence" line becomes the invariant NAME == ~(A /\\ B). A name in a +never line is a state, or a composite that stands for every state inside it. + + --module NAME The TLA+ module name. Required. + --dir OUT The folder for the files. Default: the chart's folder. + -h, --help Print this help, and exit 0. + +Writes NAME.tla, NAME.cfg, and NAME.map.mjs into OUT, and replaces files with those names. +NAME.cfg has SPECIFICATION Spec, INVARIANT TypeOK, and one INVARIANT per never line. +NAME.map.mjs gives tlc-to-chart.mjs the reader's words: the region states joined by " | ". + +Exit codes + 0 It wrote the files. + 1 It cannot read the chart. The error says why. + 2 A bad argument.`; +const more = "Run node chart-to-tla.mjs --help for the flags."; + +const args = {}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--module") args.module = process.argv[++i]; + else if (a === "--dir") args.dir = process.argv[++i]; + else if (!a.startsWith("-") && !args.chart) args.chart = a; + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } +} +if (!args.chart || !/^[A-Za-z][A-Za-z0-9_]*$/.test(args.module ?? "")) { + console.error(`${USAGE}\n${more}`); + process.exit(2); +} +const dir = args.dir ?? path.dirname(args.chart); +const { regions, nevers } = parseMermaid(fs.readFileSync(args.chart, "utf8")); + +const RESERVED = new Set(["vars", "Init", "Next", "Spec", "TypeOK", "Settled"]); +const taken = new Set(RESERVED); +function ident(words, lower) { + const parts = words + .normalize("NFKD") + .replace(/[^A-Za-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); + let id = + parts + .map((p, i) => (lower && i === 0 ? p[0].toLowerCase() : p[0].toUpperCase()) + p.slice(1)) + .join("") || "Step"; + if (/^[0-9]/.test(id)) id = `${lower ? "r" : "S"}${id}`; + let unique = id; + for (let n = 2; taken.has(unique); n++) unique = `${id}${n}`; + taken.add(unique); + return unique; +} +const str = (s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; +const set = (xs) => `{${[...xs].map(str).join(", ")}}`; + +const vars = Object.entries(regions).map(([name, r]) => { + if (r.states.size === 0) throw new Error(`region ${name} has no states`); + if (r.initial.size === 0) throw new Error(`region ${name} has no [*] --> start`); + return { name, v: ident(name, true), ...r }; +}); + +function guardOf(g) { + if (!g) return null; + const parts = Object.entries(g.states).map(([name, states]) => { + const { v } = vars.find((x) => x.name === name); + return states.length === 1 ? `${v} = ${str(states[0])}` : `${v} \\in ${set(states)}`; + }); + return g.not ? `~(${parts.join(" \\/ ")})` : parts.join(" /\\ "); +} + +const actions = []; +const byLabel = new Map(); +for (const [ri, r] of vars.entries()) { + for (const t of r.transitions) { + let a = t.label ? byLabel.get(t.label) : null; + if (!a) { + a = { label: t.label, moves: new Map() }; + actions.push(a); + if (t.label) byLabel.set(t.label, a); + } + if (!a.moves.has(ri)) a.moves.set(ri, []); + const moves = a.moves.get(ri); + const when = guardOf(t.guard); + if (!moves.some((m) => m.from === t.from && m.to === t.to && m.when === when)) + moves.push({ from: t.from, to: t.to, when }); + } +} +for (const a of actions) { + const only = a.label ? null : [...a.moves.values()][0][0]; + a.name = ident(a.label || `${only.from} to ${only.to}`, false); +} + +const out = []; +const conjuncts = (lines) => lines.map((l) => ` /\\ ${l}`); +out.push(`---- MODULE ${args.module} ----`); +out.push(`\\* Generated by chart-to-tla.mjs from ${path.basename(args.chart)}.`); +out.push("\\* Each region is one variable and each transition label is one action."); +out.push("\\* Once you add guards and variables from the real code, this file is the source."); +out.push( + `\\* statechart.mjs from-chart ${path.basename(args.chart)} keeps it, checks it, and compares it with the chart.`, +); +out.push(""); +out.push(`VARIABLES ${vars.map((x) => x.v).join(", ")}`); +out.push(`vars == <<${vars.map((x) => x.v).join(", ")}>>`); +out.push(""); +for (const x of vars) + out.push(`\\* Region ${x.name}`, `${x.v}States == ${set([...x.states].sort())}`); +out.push(""); +out.push("TypeOK ==", ...conjuncts(vars.map((x) => `${x.v} \\in ${x.v}States`))); +out.push(""); +out.push( + "Init ==", + ...conjuncts( + vars.map((x) => + x.initial.size === 1 ? `${x.v} = ${str([...x.initial][0])}` : `${x.v} \\in ${set(x.initial)}`, + ), + ), +); +const enabled = (v, m) => `${v} = ${str(m.from)}${m.when ? ` /\\ ${m.when}` : ""}`; +function whetherTakes(a, ri) { + const { v } = vars[ri]; + const moves = a.moves.get(ri); + if (moves.every((m) => !m.when)) { + const from = set(new Set(moves.map((m) => m.from))); + return { can: `${v} \\in ${from}`, cannot: `${v} \\notin ${from}` }; + } + const any = moves.map((m) => `(${enabled(v, m)})`).join(" \\/ "); + return { can: any, cannot: `~(${any})` }; +} +for (const a of actions) { + out.push(""); + if (a.label) out.push(`\\* ${a.label}`); + out.push(`${a.name} ==`); + const moving = [...a.moves.keys()]; + const shared = moving.length > 1; + if (shared) out.push(` /\\ (${moving.map((ri) => whetherTakes(a, ri).can).join(" \\/ ")})`); + for (const ri of moving) { + const { v } = vars[ri]; + const ds = a.moves.get(ri).map((m) => `(${enabled(v, m)} /\\ ${v}' = ${str(m.to)})`); + if (shared) ds.push(`(${whetherTakes(a, ri).cannot} /\\ UNCHANGED ${v})`); + out.push(` /\\ \\/ ${ds[0]}`); + for (const d of ds.slice(1)) out.push(` \\/ ${d}`); + } + const still = vars.filter((_, ri) => !a.moves.has(ri)).map((x) => x.v); + if (still.length) out.push(` /\\ UNCHANGED <<${still.join(", ")}>>`); +} +out.push(""); +out.push( + actions.length ? `Next == ${actions.map((a) => a.name).join(" \\/ ")}` : "Next == UNCHANGED vars", +); +out.push(""); +out.push("Spec == Init /\\ [][Next]_vars"); + +function resolve(item) { + const hits = []; + for (const [ri, x] of vars.entries()) { + const inside = x.states.has(item) + ? [item] + : [...x.states].filter((s) => s.startsWith(`${item}.`)); + if (inside.length) hits.push({ ri, inside }); + } + if (hits.length === 0) + throw new Error(`never line names "${item}", which is not a state in the chart`); + if (hits.length > 1) + throw new Error( + `never line names "${item}", which is a state in regions ${hits.map((h) => vars[h.ri].name).join(" and ")}; rename one`, + ); + return hits[0]; +} +const invariants = []; +for (const n of nevers) { + if (taken.has(n.name)) throw new Error(`never name ${n.name} is already used in the spec`); + taken.add(n.name); + const parts = n.states.map(resolve); + const regionsUsed = new Set(parts.map((p) => p.ri)); + if (regionsUsed.size !== parts.length) + throw new Error( + `never ${n.name} names two states of one region; one region is in one state at a time`, + ); + const cond = parts.map((p) => + p.inside.length === 1 + ? `${vars[p.ri].v} = ${str(p.inside[0])}` + : `${vars[p.ri].v} \\in ${set(p.inside)}`, + ); + out.push(""); + if (n.sentence) out.push(`\\* ${n.sentence}`); + out.push(`${n.name} == ~(${cond.join(" /\\ ")})`); + invariants.push(n.name); +} +out.push("", "====", ""); + +const rules = ["TypeOK", ...invariants]; +const cfg = ["SPECIFICATION Spec", ...rules.map((r) => `INVARIANT ${r}`), ""]; + +const labels = Object.fromEntries(actions.map((a) => [a.name, a.label])); +const map = [ + `// Generated by chart-to-tla.mjs from ${path.basename(args.chart)}. Reader states are the chart's own states.`, + `export const regions = ${JSON.stringify(vars.map((x) => x.v))};`, + `const LABELS = ${JSON.stringify(labels, null, "\t")};`, + "", + "// The actions the reader takes. facts.settled needs them. Add cause() and detail() as the spec grows.", + "export const readerActions = [];", + 'export const state = (v) => regions.map((r) => v[r]).join(" | ");', + "export const step = (action) => LABELS[action] ?? action;", + "", +]; + +fs.mkdirSync(dir, { recursive: true }); +fs.writeFileSync(path.join(dir, `${args.module}.tla`), out.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.cfg`), cfg.join("\n")); +fs.writeFileSync(path.join(dir, `${args.module}.map.mjs`), map.join("\n")); +console.error( + `${args.module}: ${vars.length} region variable(s), ${actions.length} action(s), rules ${rules.join(" ")} -> ${path.join(dir, args.module)}.{tla,cfg,map.mjs}`, +); diff --git a/skills/statechart/scripts/compare-charts.mjs b/skills/statechart/scripts/compare-charts.mjs new file mode 100644 index 0000000..587125a --- /dev/null +++ b/skills/statechart/scripts/compare-charts.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import process from "node:process"; +import { FULL_LABELS, LEGEND_ID, compareCharts, parseMermaid } from "./lib.mjs"; + +const USAGE = "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"; +const HELP = `${USAGE} + +Compares two Mermaid stateDiagram-v2 charts: reachable states, start states, and labeled +transitions. statechart.mjs from-chart runs it to check that the spec still draws the chart that +the reader agreed to. + +EXPECTED is the agreed chart. ACTUAL is what tlc-to-chart.mjs drew from the spec in the customer +view with --highlight 0 --full-labels --flat, without --cover, --fixed-dot, or --against-dot. Each +chart compares as the +machine that its steps reach from its start states. A chart with parallel regions compares as its +combined machine, so a guard that blocks a step in only some combinations of regions shows as a +difference. A "%% when ID, ID" or "%% when not ID, ID" line right after a transition is its guard: +the step happens only while another region is in, or not in, one of those states. The label ends +with the guard's words in [ ], which are not part of the label. A combined state is the region +states joined by " | " in region order, which is what chart-to-tla.mjs's mapping writes. A step that does not change the state is not compared, because +tlc-to-chart.mjs does not draw it. An edge label "a or b" counts as two labels. An unlabeled edge +counts only when no labeled edge joins the same two states, because tlc-to-chart.mjs draws only +the labels. + + -h, --help Print this help, and exit 0. + +Prints each difference and then "N difference(s)", or prints "same". Writes no file. + +Exit codes + 0 The charts are the same. + 1 The charts differ. + 2 A bad argument, a chart that it cannot read, or an ACTUAL chart that is not drawn for + comparison.`; +const more = "Run node compare-charts.mjs --help for the flags."; + +const files = []; +for (const a of process.argv.slice(2)) { + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a.startsWith("-") || files.length === 2) { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } else files.push(a); +} +if (files.length < 2) { + console.error(`${USAGE}\n${more}`); + process.exit(2); +} +const [expectedFile, actualFile] = files; + +const orExit = (file, f) => { + try { + return f(); + } catch (e) { + console.error(`${file}: ${e.message}`); + process.exit(2); + } +}; +const [expectedText, actualText] = files.map((f) => orExit(f, () => fs.readFileSync(f, "utf8"))); +const has = (re) => re.test(actualText); +const textRefusals = [ + [ + has(/^\s*%% second model/m), + "it has a second model; draw ACTUAL without --fixed-dot or --against-dot", + ], + [ + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+(?!${LEGEND_ID}\\b)\\w+\\s*\\{`, "m")), + "it has boxes; draw ACTUAL with --flat", + ], + [ + has(new RegExp(`^\\s*state\\s+"[^"]*"\\s+as\\s+${LEGEND_ID}\\s*\\{`, "m")) && + !actualText.includes(FULL_LABELS), + "edge labels write shared words once; draw ACTUAL with --full-labels", + ], +]; +for (const [bad, why] of textRefusals) { + if (bad) { + console.error(`${actualFile}: ${why}`); + process.exit(2); + } +} +const expectedRegions = orExit(expectedFile, () => parseMermaid(expectedText).regions); +const actualRegions = orExit(actualFile, () => parseMermaid(actualText).regions); +const numbered = Object.values(actualRegions) + .flatMap((r) => r.transitions) + .some((t) => /^\d+(, \d+)*\. /.test(t.label)); +if (numbered) { + console.error( + `${actualFile}: an edge label is a numbered path step; draw ACTUAL with --highlight 0`, + ); + process.exit(2); +} + +const rows = compareCharts(expectedRegions, actualRegions); +let differences = 0; +for (const [title, xs] of rows) { + console.log(`${title}:`); + for (const x of xs) console.log(` ${x}`); + differences += xs.length; +} +console.log(differences === 0 ? "same" : `${differences} difference(s)`); +process.exit(differences === 0 ? 0 : 1); diff --git a/skills/statechart/scripts/lib.mjs b/skills/statechart/scripts/lib.mjs new file mode 100644 index 0000000..cf72d5c --- /dev/null +++ b/skills/statechart/scripts/lib.mjs @@ -0,0 +1,723 @@ +import fs from "node:fs"; +import process from "node:process"; + +export function findTlc() { + const { TLA2TOOLS, JAVA } = process.env; + if (!TLA2TOOLS) + throw new Error( + "Cannot find TLC. Download tla2tools.jar from a TLA+ release:\n https://github.com/tlaplus/tlaplus/releases\nThen set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).", + ); + if (!fs.existsSync(TLA2TOOLS)) throw new Error(`TLA2TOOLS is ${TLA2TOOLS}, which does not exist.`); + return { jar: TLA2TOOLS, java: JAVA || "java" }; +} + +export function parseValue(text) { + const src = text.trim(); + let i = 0; + const ws = () => { + while (i < src.length && /\s/.test(src[i])) i++; + }; + const peek = (s) => src.startsWith(s, i); + const eat = (s) => { + ws(); + if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`); + i += s.length; + }; + const list = (close, item) => { + const out = []; + ws(); + if (peek(close)) { + i += close.length; + return out; + } + for (;;) { + out.push(item()); + ws(); + if (peek(",")) { + i++; + continue; + } + eat(close); + return out; + } + }; + const value = () => { + ws(); + if (peek("<<")) { + i += 2; + return list(">>", value); + } + if (peek("{")) { + i++; + return list("}", value); + } + if (peek("[")) { + i++; + const pairs = list("]", () => { + ws(); + const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad record field at ${i}`); + i += m[0].length; + eat("|->"); + return [m[0], value()]; + }); + return Object.fromEntries(pairs); + } + if (peek("(")) { + i++; + const fn = {}; + for (;;) { + const k = value(); + eat(":>"); + fn[typeof k === "string" ? k : JSON.stringify(k)] = value(); + ws(); + if (peek("@@")) { + i += 2; + continue; + } + eat(")"); + return fn; + } + } + if (peek('"')) { + i++; + let s = ""; + while (i < src.length && src[i] !== '"') { + if (src[i] === "\\") i++; + s += src[i++]; + } + i++; + return s; + } + const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i)); + if (!m) throw new Error(`bad value at ${i} in ${src}`); + i += m[0].length; + if (m[0] === "TRUE") return true; + if (m[0] === "FALSE") return false; + return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0]; + }; + try { + const v = value(); + ws(); + return i === src.length ? v : src; + } catch { + return src; + } +} + +// One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable. +function parseState(text) { + const lines = text.split("\n").filter((l) => l.trim() !== ""); + const conjuncts = []; + for (const line of lines) { + if (/^\s*\/\\ /.test(line) || conjuncts.length === 0) + conjuncts.push(line.replace(/^\s*\/\\ /, "")); + else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`; + } + const vars = {}; + for (const c of conjuncts) { + const eq = c.indexOf(" = "); + if (eq < 0) continue; + vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3)); + } + return vars; +} + +export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +const canon = (v) => + Array.isArray(v) + ? v.map(canon) + : isRecord(v) + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, canon(v[k])]), + ) + : v; +export const stateKey = (vars) => JSON.stringify(canon(vars)); + +export function ruleSentences(tla) { + const out = new Map(); + const lines = tla.split("\n"); + lines.forEach((line, i) => { + const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1]; + if (!name) return; + const said = []; + for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--) + said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim()); + const sentence = said.filter(Boolean).join(" "); + if (sentence) out.set(name, sentence); + }); + return out; +} + +function unescapeDot(s) { + let out = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && i + 1 < s.length) { + const n = s[++i]; + out += n === "n" ? "\n" : n; + } else out += s[i]; + } + return out; +} + +export function parseDot(text) { + const nodes = new Map(); + const edges = []; + const loops = []; + const seen = new Set(); + const LABEL = '"((?:[^"\\\\]|\\\\.)*)"'; + const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`); + const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`); + for (const line of text.split("\n")) { + let m = edgeRe.exec(line); + if (m) { + const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" }; + const id = `${e.from} ${e.to} ${e.action}`; + if (seen.has(id)) continue; + seen.add(id); + (e.from === e.to ? loops : edges).push(e); + continue; + } + m = nodeRe.exec(line); + if (m) + nodes.set(m[1], { + id: m[1], + vars: parseState(unescapeDot(m[2])), + init: /style\s*=\s*filled/.test(m[3]), + }); + } + if (nodes.size === 0) + throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels "); + const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to)); + if (missing) + throw new Error( + `transition ${missing.from} -> ${missing.to} names a state the file does not define`, + ); + if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action)) + throw new Error( + "the graph has no action names; dump it with TLC -dump dot,actionlabels ", + ); + return { nodes, edges, loops }; +} + +export function parseTraces(text) { + const traces = []; + let invariant = null; + let current = null; + const lines = text.split("\n"); + const stateAt = (i) => { + const body = []; + while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]); + return [parseState(body.join("\n")), i]; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line); + if (inv) { + invariant = inv[1]; + if (inv[2]) { + const [vars, end] = stateAt(i); + traces.push({ invariant, steps: [{ action: null, vars }] }); + i = end; + } + continue; + } + if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is")) + invariant = null; + const head = /^State (\d+): <([^\s>(]+)/.exec(line); + if (!head) continue; + const [vars, end] = stateAt(i); + i = end; + if (head[1] === "1") { + current = { invariant, steps: [] }; + traces.push(current); + } + current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars }); + } + return traces; +} + +// With -continue, TLC prints "Model checking completed" even after broken invariants, and not +// when it stopped early. +export function checkTlcOutput(text, file) { + const other = text + .split("\n") + .find( + (l) => + l.startsWith("Error: ") && + !/^Error: (Invariant \S+ is violated|The behavior up to this point is)/.test(l), + ); + if (other) + throw new Error( + `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`, + ); + if (!/^Model checking completed\./m.test(text)) + throw new Error( + `${file}: TLC did not finish, so its counterexamples are not all there; read the file`, + ); +} + +export function wrap(text, width = 26) { + const words = String(text).split(/\s+/); + const rows = []; + let row = ""; + for (const w of words) { + if (row && (row + " " + w).length > width) { + rows.push(row); + row = w; + } else row = row ? `${row} ${w}` : w; + } + if (row) rows.push(row); + return rows.join("
"); +} + +const OPENS_A_PHRASE = new Set( + "a an the your my our their its this that to of in on at for with from by and or".split(" "), +); +export function joinLabels(labels) { + if (labels.length < 2) return labels.join(" or "); + const words = labels.map((l) => String(l).split(" ")); + let n = 0; + while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++; + while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--; + if (n < 2) return labels.join(" or "); + const rest = words.map((w) => w.slice(n).join(" ")); + const list = + rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`; + return `${words[0].slice(0, n).join(" ")} ${list}`; +} + +const unwrap = (s) => + s + .replace(/|#160;/g, " ") + .replace(/#(quot|34);/g, '"') + .replace(/\s+/g, " ") + .trim(); + +// Mermaid ends a quoted state caption at the first ", so a caption writes " as #quot;. +export const quote = (s) => String(s).replaceAll('"', "#quot;"); + +export const LEGEND_ID = "legend"; +export const FULL_LABELS = "%% labels in full"; + +export function parseMermaid(text) { + const names = new Map(); + const nevers = []; + let last = null; + const newPart = () => ({ children: [], transitions: [] }); + const root = { name: "", parts: [newPart()] }; + const stack = [root]; + const part = () => stack[stack.length - 1].parts.at(-1); + const display = (id) => names.get(id) ?? id; + const implicit = new Map(); + const declare = (id) => { + const prev = implicit.get(id); + if (prev) + prev.children.splice( + prev.children.findIndex((c) => c.name === id), + 1, + ); + implicit.set(id, null); + }; + const mention = (id) => { + if (id === "[*]" || implicit.has(id)) return; + part().children.push({ name: id, leaf: true }); + implicit.set(id, part()); + }; + const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, ""); + for (const raw of body.split("\n")) { + const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); + const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); + if (when) { + if (!last) throw new Error(`a %% when line must come right after a transition: ${line}`); + if (!last.words) + throw new Error( + `the guarded step "${last.label}" must end its label with the guard's words in [ ]`, + ); + last.when = { not: Boolean(when[1]), ids: when[2].split(",").map((s) => s.trim()) }; + last = null; + continue; + } + last = null; + const never = /^%%\s*never\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+?)(?:\s+--\s+(.*))?$/.exec(line); + if (never) { + nevers.push({ + name: never[1], + states: never[2].split("&").map((s) => s.trim()), + sentence: never[3] ?? "", + }); + continue; + } + if ( + !line || + line.startsWith("%%") || + /^(stateDiagram|direction|classDef|class |note)/.test(line) + ) + continue; + let m = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (!m) { + const plain = /^state\s+([A-Za-z_]\w*)\s*(\{)?\s*$/.exec(line); + if (plain) m = [line, null, plain[1], plain[2]]; + } + if (m) { + if (m[1] !== null && !names.has(m[2])) names.set(m[2], unwrap(m[1])); + declare(m[2]); + if (m[3]) { + const node = { name: m[2], parts: [newPart()] }; + part().children.push(node); + stack.push(node); + } else part().children.push({ name: m[2], leaf: true }); + continue; + } + if (line === "}") { + if (stack.length === 1) throw new Error("unmatched } in the chart"); + stack.pop(); + continue; + } + if (line === "--") { + stack[stack.length - 1].parts.push(newPart()); + continue; + } + m = /^(\[\*\]|[A-Za-z_]\w*)\s*-->\s*(\[\*\]|[A-Za-z_]\w*)(?:\s*:\s*(.*))?$/.exec(line); + if (m) { + if (m[2] === "[*]" && m[1] !== "[*]") + throw new Error( + `cannot read final state in "${line}"; draw the step to the state the reader sees next`, + ); + mention(m[1]); + mention(m[2]); + const said = m[3] ? unwrap(m[3]) : ""; + const label = said.replace(/\s*\[[^\]]*\]/g, ""); + last = { from: m[1], to: m[2], label, words: label !== said }; + part().transitions.push(last); + continue; + } + m = /^([A-Za-z_]\w*)\s*:\s*(.*)$/.exec(line); + if (m) { + if (!names.has(m[1])) names.set(m[1], unwrap(m[2])); + if (implicit.get(m[1]) === null) continue; + declare(m[1]); + part().children.push({ name: m[1], leaf: true }); + continue; + } + throw new Error(`cannot read chart line: ${line}`); + } + if (stack.length !== 1) throw new Error("the chart has an unclosed {"); + if (root.parts.length > 1) + throw new Error( + "the chart has -- outside a composite; to make parallel regions, split one top-level composite with --", + ); + + // Ids are global, as in Mermaid: a transition may name a state declared inside another composite. + function region(p) { + const out = { states: new Set(), initial: new Set(), transitions: [] }; + const info = new Map(); + const declared = new Set(); + (function collect(q) { + for (const c of q.children) { + if (c.name === LEGEND_ID) continue; + declared.add(c.name); + if (!c.leaf) for (const sub of c.parts) collect(sub); + } + })(p); + const transitions = []; + const lookup = (id) => { + const found = info.get(id); + if (!found) throw new Error(`transition names ${id}, which is not in this region`); + return found; + }; + function states(q, prefix, depth) { + const local = q.children.filter((c) => c.name !== LEGEND_ID); + for (const t of q.transitions) { + for (const end of [t.from, t.to]) { + if (end === "[*]" || declared.has(end)) continue; + declared.add(end); + local.push({ name: end, leaf: true }); + } + } + const all = []; + for (const c of local) { + if (info.has(c.name)) { + if (local.indexOf(c) !== local.findIndex((x) => x.name === c.name)) continue; + throw new Error(`state id ${c.name} is declared in two places`); + } + const full = `${prefix}${display(c.name)}`; + if (c.leaf) { + if (out.states.has(full)) + throw new Error(`two states have the caption "${full}"; give each its own caption`); + out.states.add(full); + info.set(c.name, { all: [full], initial: [full], depth }); + all.push(full); + continue; + } + if (c.parts.length > 1) + throw new Error( + `${display(c.name)} splits into regions below the top level; make each region a top-level region`, + ); + const inner = states(c.parts[0], `${full}.`, depth + 1); + const initial = c.parts[0].transitions + .filter((t) => t.from === "[*]") + .flatMap((t) => lookup(t.to).initial); + if (initial.length === 0) + throw new Error( + `${display(c.name)} has no [*] --> start, so a transition into it has nowhere to land`, + ); + info.set(c.name, { all: inner, initial, depth }); + all.push(...inner); + } + for (const t of q.transitions) if (t.from !== "[*]" && t.to !== "[*]") transitions.push(t); + return all; + } + states(p, "", 0); + for (const t of p.transitions) + if (t.from === "[*]") for (const s of lookup(t.to).initial) out.initial.add(s); + transitions.sort((a, b) => lookup(b.from).depth - lookup(a.from).depth); + const handled = new Set(); + let pending = []; + let depth = null; + for (const t of transitions) { + const from = lookup(t.from); + if (from.depth !== depth) { + for (const h of pending) handled.add(h); + pending = []; + depth = from.depth; + } + for (const f of from.all) { + const key = `${f}\u0000${t.label}`; + if (t.label && handled.has(key)) continue; + for (const to of lookup(t.to).initial) + out.transitions.push({ + from: f, + to, + label: t.label, + ...(t.when ? { when: t.when } : {}), + }); + if (t.label) pending.push(key); + } + } + return [out, info]; + } + + const owners = new Map(); + const regions = {}; + const top = root.parts[0]; + const machine = top.children.filter((c) => c.name !== LEGEND_ID); + const parallel = + machine.length === 1 && + !machine[0].leaf && + machine[0].parts.length > 1 && + top.transitions.every((t) => t.from === "[*]"); + if (parallel) { + for (const p of machine[0].parts) { + const [only] = p.children; + const holder = + p.children.length === 1 && + !only.leaf && + p.transitions.every((t) => t.from === "[*]" && t.to === only.name) + ? only + : null; + if (holder && holder.parts.length > 1) + throw new Error(`${display(holder.name)} splits into regions below the top level`); + const name = holder ? display(holder.name) : `Region${Object.keys(regions).length + 1}`; + const [r, ids] = region(holder ? holder.parts[0] : p); + regions[name] = r; + for (const [id, { all }] of ids) owners.set(id, { name, all }); + } + } else { + const [r, ids] = region(top); + regions.Main = r; + for (const [id, { all }] of ids) owners.set(id, { name: "Main", all }); + } + for (const [name, r] of Object.entries(regions)) + for (const t of r.transitions) { + if (!t.when) continue; + if (!parallel) + throw new Error("a %% when line guards a step on other regions, and the chart has none"); + const states = {}; + for (const id of t.when.ids) { + const at = owners.get(id); + if (!at) + throw new Error(`a %% when line names ${id}, which is not a state id in the chart`); + if (at.name === name) + throw new Error( + `a %% when line names ${display(id)}, which is in the step's own region; a guard reads other regions`, + ); + states[at.name] = [...new Set([...(states[at.name] ?? []), ...at.all])].sort(); + } + t.guard = { not: t.when.not, states }; + delete t.when; + } + return { regions, nevers }; +} + +function guardHolds(guard, at) { + if (!guard) return true; + const hits = Object.entries(guard.states).map(([r, states]) => states.includes(at[r])); + return guard.not ? !hits.some(Boolean) : hits.every(Boolean); +} + +export function productOf(regions) { + const names = Object.keys(regions); + const rs = Object.values(regions); + const join = (parts) => parts.join(" | "); + const byLabel = new Map(); + const unlabeled = []; + rs.forEach((r, ri) => { + for (const t of r.transitions) { + if (!t.label) { + unlabeled.push({ ri, from: t.from, to: t.to, guard: t.guard }); + continue; + } + if (!byLabel.has(t.label)) + byLabel.set( + t.label, + rs.map(() => new Map()), + ); + const moves = byLabel.get(t.label)[ri]; + if (!moves.has(t.from)) moves.set(t.from, []); + moves.get(t.from).push({ to: t.to, guard: t.guard }); + } + }); + let starts = [[]]; + for (const r of rs) starts = starts.flatMap((p) => [...r.initial].map((s) => [...p, s])); + const out = { states: new Set(), initial: new Set(starts.map(join)), transitions: [] }; + const seen = new Set(starts.map(join)); + const queue = [...starts]; + const step = (from, to, label) => { + const [a, b] = [join(from), join(to)]; + if (a === b) return; + out.transitions.push({ from: a, to: b, label }); + if (!seen.has(b)) { + seen.add(b); + queue.push(to); + } + }; + while (queue.length) { + const cur = queue.shift(); + out.states.add(join(cur)); + const at = Object.fromEntries(names.map((n, ri) => [n, cur[ri]])); + for (const u of unlabeled) { + if (cur[u.ri] !== u.from || !guardHolds(u.guard, at)) continue; + const next = [...cur]; + next[u.ri] = u.to; + step(cur, next, ""); + } + for (const [label, perRegion] of byLabel) { + const open = perRegion.map((moves, ri) => + (moves.get(cur[ri]) ?? []).filter((m) => guardHolds(m.guard, at)).map((m) => m.to), + ); + if (open.every((tos) => tos.length === 0)) continue; + let nexts = [[]]; + open.forEach((tos, ri) => { + const options = tos.length ? [...new Set(tos)] : [cur[ri]]; + nexts = nexts.flatMap((p) => options.map((s) => [...p, s])); + }); + for (const next of nexts) step(cur, next, label); + } + } + return out; +} + +export function compareCharts(expectedRegions, actualRegions) { + const expected = productOf(expectedRegions); + const actual = productOf(actualRegions); + const edgeSet = (m) => { + const labeled = new Set(); + const out = new Set(); + for (const t of m.transitions) { + if (!t.label) continue; + labeled.add(`${t.from} --> ${t.to}`); + for (const l of t.label.split(" or ")) out.add(`${t.from} --> ${t.to} : ${l}`); + } + for (const t of m.transitions) + if (!t.label && !labeled.has(`${t.from} --> ${t.to}`)) out.add(`${t.from} --> ${t.to}`); + return out; + }; + const minus = (a, b) => [...a].filter((x) => !b.has(x)).sort(); + const unreached = []; + const regionEntries = Object.entries(expectedRegions); + if (regionEntries.length === 1) { + for (const s of regionEntries[0][1].states) if (!expected.states.has(s)) unreached.push(s); + } else { + regionEntries.forEach(([name, r], ri) => { + const seen = new Set([...expected.states].map((s) => s.split(" | ")[ri])); + for (const s of r.states) if (!seen.has(s)) unreached.push(`${name}: ${s}`); + }); + } + return [ + ["states the agreed chart's own steps never reach", unreached.sort()], + [ + "states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them)", + minus(expected.states, actual.states), + ], + ["states only in ACTUAL", minus(actual.states, expected.states)], + ["start states only in EXPECTED", minus(expected.initial, actual.initial)], + ["start states only in ACTUAL", minus(actual.initial, expected.initial)], + [ + "transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step)", + minus(edgeSet(expected), edgeSet(actual)), + ], + ["transitions only in ACTUAL", minus(edgeSet(actual), edgeSet(expected))], + ].filter(([, xs]) => xs.length > 0); +} + +export const STATUS_CLASSES = [ + "classDef works fill:#f4f6f7,stroke:#566573,color:#1b2631", + "classDef wrong fill:#fdedec,stroke:#c0392b,color:#7b241c", + "classDef fixed fill:#e9f7ef,stroke:#1e8449,color:#145a32", + "classDef worksPath fill:#f4f6f7,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + "classDef wrongPath fill:#fdedec,stroke:#1f3a93,stroke-width:4px,color:#7b241c", + "classDef fixedPath fill:#e9f7ef,stroke:#1f3a93,stroke-width:4px,color:#145a32", +]; + +export const GROUP_PALETTE = [ + "#d6eaf8", + "#fdebd0", + "#e8daef", + "#fcf3cf", + "#d1f2eb", + "#ede0d4", + "#f6e0ef", +]; +export const PLAIN_FILL = "#f4f6f7"; + +const rgb = (hex) => { + const h = hex.length === 4 ? [...hex.slice(1)].map((c) => c + c).join("") : hex.slice(1); + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); +}; +// WCAG relative luminance: 0 for black, 1 for white. +function luminance(hex) { + const [r, g, b] = rgb(hex).map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +export function groupColorProblem(color) { + if (typeof color !== "string" || !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) + return 'is not "#rgb" or "#rrggbb"'; + if (luminance(color) < 0.5) return "is too dark for the status borders to show on it"; + return null; +} + +const BORDERS = { + Wrong: ["stroke:#c0392b,stroke-width:4px", "#7b241c"], + Fixed: ["stroke:#1e8449,stroke-width:4px", "#145a32"], + Path: ["stroke:#1f3a93,stroke-width:4px", "#1b2631"], +}; +export function groupClassDef(base, fill, border) { + const darker = `#${rgb(fill) + .map((c) => + Math.round(c / 2) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; + const [stroke, color] = BORDERS[border] ?? [ + `stroke:${fill === PLAIN_FILL ? "#566573" : darker}`, + "#1b2631", + ]; + return `classDef ${base}${border} fill:${fill},${stroke},color:${color}`; +} diff --git a/skills/statechart/scripts/model/OutputFolder.cfg b/skills/statechart/scripts/model/OutputFolder.cfg new file mode 100644 index 0000000..68fd715 --- /dev/null +++ b/skills/statechart/scripts/model/OutputFolder.cfg @@ -0,0 +1,6 @@ +SPECIFICATION Spec +INVARIANT ChartsOneSpec +INVARIANT KeepsYourFiles +INVARIANT NeverLosesChart +INVARIANT ExitTwoLeavesFolder +INVARIANT OverlapNeverRefuses diff --git a/skills/statechart/scripts/model/OutputFolder.map.mjs b/skills/statechart/scripts/model/OutputFolder.map.mjs new file mode 100644 index 0000000..9618338 --- /dev/null +++ b/skills/statechart/scripts/model/OutputFolder.map.mjs @@ -0,0 +1,106 @@ +const QUIET = ["idle", "exited", "stopped", "orphanTlc"]; +const going = (v) => v.pc.filter((p) => !QUIET.includes(p)).length; +const changed = (before, after) => + after.pc.findIndex( + (p, i) => p !== before.pc[i] || after.aside[i].exists !== before.aside[i].exists, + ) + 1; + +export const readerActions = ["Start", "Edit", "Note", "Stop", "KillNode"]; + +function context(v) { + if (v.pc.every((p) => p === "idle")) return "Before any run"; + if (going(v) === 2) return "Two runs going"; + return going(v) === 1 ? "One run going" : "Done"; +} + +const SPECS = { 1: "the spec", 2: "the edited spec", "1,2": "both specs" }; + +function contents(v) { + const kept = v.aside.filter((a) => a.exists); + const aside = kept.some((a) => a.notes) + ? ", your notes moved aside" + : kept.some((a) => a.by) + ? ", the old chart moved aside" + : ""; + const parts = [ + v.out.by && `chart of ${SPECS[v.out.read.join()]}`, + v.out.notes && "your notes", + ].filter(Boolean); + if (!v.out.exists) return `no folder${aside}`; + return `${parts.join(" and ") || "empty folder"}${aside}`; +} + +export function state(v) { + return `${context(v)}: ${contents(v)}`; +} + +export function group(v) { + if (v.pc.every((p) => p === "idle")) return "Not started"; + return going(v) ? "Running" : "Finished"; +} + +export function step(action, before, after) { + const r = changed(before, after); + switch (action) { + case "Start": { + const where = going(before) ? " in a second shell" : ""; + const how = after.inputOk[r - 1] ? "" : " with a typo"; + return `you start run ${r}${where}${how}`; + } + case "Refuse": + return before.inputOk[r - 1] + ? `run ${r} says the folder has a file that it did not write` + : `run ${r} says the input is wrong`; + case "Tlc": + return `run ${r}'s TLC reads the spec`; + case "Draw": + return `run ${r} draws the chart in its own folder`; + case "Stale": + return `run ${r} says the spec changed while it read it`; + case "Foreign": + return `run ${r} says the folder has a file that it did not write`; + case "Swap": + case "Checked": + return ""; + case "MoveIn": + return `run ${r} moves its folder into place`; + case "MoveAside": + return `run ${r} moves the old folder aside`; + case "Restore": + return `run ${r} puts the folder with your notes back and stops`; + case "Supersede": + return `run ${r} finds another run's folder in place and deletes the old folder`; + case "KeepAside": + return `run ${r} keeps the folder with your notes aside and says where`; + case "RenameFails": + if (after.aside[r - 1].exists) + return `run ${r} cannot rename a folder and says where the old folder is`; + return after.out.exists && !before.out.exists + ? `run ${r} cannot rename a folder, puts the old folder back, and stops` + : `run ${r} cannot rename a folder and stops`; + case "Clean": + return `run ${r} deletes the old folder and finishes`; + case "Edit": + return "you edit the spec"; + case "Note": + return "you save notes in the folder"; + case "Stop": + return `you stop run ${r} with Ctrl-C`; + case "KillNode": + return `you kill run ${r}'s Node, and its TLC keeps running`; + case "OrphanEnd": + return `run ${r}'s orphaned TLC finishes`; + default: + return action; + } +} + +export function cause(v, facts, invariant) { + return { + ChartsOneSpec: "the run read the spec before and after an edit, and still wrote its chart", + KeepsYourFiles: "a run deleted a folder that had your notes in it", + NeverLosesChart: "a run deleted the last chart before another chart was in place", + ExitTwoLeavesFolder: "a run wrote the folder and then said it could not finish", + OverlapNeverRefuses: "a run stopped only because another run overlapped it", + }[invariant]; +} diff --git a/skills/statechart/scripts/model/OutputFolder.tla b/skills/statechart/scripts/model/OutputFolder.tla new file mode 100644 index 0000000..7765bab --- /dev/null +++ b/skills/statechart/scripts/model/OutputFolder.tla @@ -0,0 +1,176 @@ +---- MODULE OutputFolder ---- +EXTENDS Naturals + +Runs == {1, 2} +NoRun == 0 +NoFolder == [exists |-> FALSE, by |-> NoRun, read |-> {}, notes |-> FALSE] + +VARIABLES pc, inputOk, snap, read, out, aside, spec, noted, wrote, broke, exitCode + +vars == <> + +Init == + /\ pc = [r \in Runs |-> "idle"] + /\ inputOk = [r \in Runs |-> TRUE] + /\ snap = [r \in Runs |-> 0] + /\ read = [r \in Runs |-> {}] + /\ out = NoFolder + /\ aside = [r \in Runs |-> NoFolder] + /\ spec = 1 + /\ noted = FALSE + /\ wrote = [r \in Runs |-> FALSE] + /\ broke = [r \in Runs |-> FALSE] + /\ exitCode = [r \in Runs |-> "none"] + +Set(f, r, v) == [f EXCEPT ![r] = v] +Going(r) == pc[r] \notin {"idle", "exited", "stopped", "orphanTlc"} +Go(r, to) == pc' = Set(pc, r, to) +Exit(r, code) == pc' = Set(pc, r, "exited") /\ exitCode' = Set(exitCode, r, code) + +Start(r) == + /\ pc[r] = "idle" + /\ r = 2 => pc[1] # "idle" + /\ \E ok \in BOOLEAN : inputOk' = Set(inputOk, r, ok) + /\ snap' = Set(snap, r, spec) + /\ Go(r, "check") + /\ UNCHANGED <> + +Refuse(r) == + /\ pc[r] = "check" + /\ ~inputOk[r] \/ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Tlc(r) == + /\ \/ pc[r] = "check" /\ inputOk[r] /\ ~out.notes + \/ pc[r] = "tlc" + /\ read' = Set(read, r, read[r] \cup {spec}) + /\ Go(r, IF pc[r] = "check" THEN "tlc" ELSE "draw") + /\ UNCHANGED <> + +Draw(r) == + /\ pc[r] = "draw" + /\ Go(r, "publish") + /\ UNCHANGED <> + +Stale(r) == + /\ pc[r] = "publish" /\ spec # snap[r] + /\ Exit(r, "2") + /\ UNCHANGED <> + +Foreign(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +Swap(r) == + /\ pc[r] = "publish" /\ spec = snap[r] /\ ~out.notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +MoveIn(r) == + /\ pc[r] = "swap" /\ ~out.exists + /\ out' = [exists |-> TRUE, by |-> r, read |-> read[r], notes |-> FALSE] + /\ wrote' = Set(wrote, r, TRUE) + /\ Go(r, "clean") + /\ UNCHANGED <> + +MoveAside(r) == + /\ pc[r] = "swap" /\ out.exists /\ ~aside[r].exists + /\ aside' = Set(aside, r, out) + /\ out' = NoFolder + /\ Go(r, "aside") + /\ UNCHANGED <> + +Checked(r) == + /\ pc[r] = "aside" /\ ~aside[r].notes + /\ Go(r, "swap") + /\ UNCHANGED <> + +Supersede(r) == + /\ pc[r] = "swap" /\ out.exists /\ aside[r].exists /\ ~out.notes + /\ aside' = Set(aside, r, NoFolder) + /\ UNCHANGED <> + +Restore(r) == + /\ pc[r] = "aside" /\ aside[r].notes /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "2") + /\ UNCHANGED <> + +KeepAside(r) == + /\ \/ pc[r] = "aside" /\ aside[r].notes /\ out.exists + \/ pc[r] = "swap" /\ aside[r].exists /\ out.notes + /\ Exit(r, "2") + /\ UNCHANGED <> + +RenameFails(r) == + /\ pc[r] \in {"swap", "aside"} + /\ \/ /\ aside[r].exists /\ ~out.exists + /\ out' = aside[r] + /\ aside' = Set(aside, r, NoFolder) + \/ UNCHANGED <> + /\ broke' = Set(broke, r, TRUE) + /\ Exit(r, "2") + /\ UNCHANGED <> + +Clean(r) == + /\ pc[r] = "clean" + /\ aside' = Set(aside, r, NoFolder) + /\ Exit(r, "0") + /\ UNCHANGED <> + +Stop(r) == + /\ Going(r) + /\ Go(r, "stopped") + /\ UNCHANGED <> + +KillNode(r) == + /\ pc[r] = "tlc" + /\ Go(r, "orphanTlc") + /\ UNCHANGED <> + +OrphanEnd(r) == + /\ pc[r] = "orphanTlc" + /\ Go(r, "stopped") + /\ UNCHANGED <> + +Edit == + /\ spec = 1 + /\ spec' = 2 + /\ UNCHANGED <> + +Note == + /\ ~noted + /\ noted' = TRUE + /\ out' = [out EXCEPT !.exists = TRUE, !.notes = TRUE] + /\ UNCHANGED <> + +Next == + \/ Edit \/ Note + \/ \E r \in Runs : + Start(r) \/ Refuse(r) \/ Tlc(r) \/ Draw(r) \/ Stale(r) \/ Foreign(r) \/ Swap(r) + \/ MoveIn(r) \/ MoveAside(r) \/ Checked(r) \/ Supersede(r) \/ Restore(r) + \/ KeepAside(r) \/ RenameFails(r) \/ Clean(r) \/ Stop(r) \/ KillNode(r) \/ OrphanEnd(r) + +Spec == Init /\ [][Next]_vars + +\* The folder never holds a chart drawn from two versions of the spec. +ChartsOneSpec == out.read \in {{}, {1}, {2}} + +\* A file that you put in the folder is never deleted. +KeepsYourFiles == noted => out.notes \/ \E r \in Runs : aside[r].notes + +\* Once a run writes a chart, the folder or a folder that a run moved aside always holds one. +NeverLosesChart == + (\E r \in Runs : wrote[r]) => out.by # NoRun \/ \E r \in Runs : aside[r].by # NoRun + +\* A run that exits 2 did not write the folder. +ExitTwoLeavesFolder == \A r \in Runs : exitCode[r] = "2" => ~wrote[r] + +\* A run exits 2 only for a wrong input, an edited spec, a file that it did not write, or a failed +\* rename, never because another run overlaps it. +OverlapNeverRefuses == + \A r \in Runs : exitCode[r] = "2" => ~inputOk[r] \/ spec # snap[r] \/ noted \/ broke[r] +==== diff --git a/skills/statechart/scripts/statechart.mjs b/skills/statechart/scripts/statechart.mjs new file mode 100644 index 0000000..6e2ebfc --- /dev/null +++ b/skills/statechart/scripts/statechart.mjs @@ -0,0 +1,749 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { checkTlcOutput, findTlc, parseDot } from "./lib.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +const USAGE = { + "from-spec": + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", + "from-chart": + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]", +}; +const usage = (lines) => `Usage: ${lines.join("\n ")}`; + +const FROM_SPEC = `from-spec SPEC.tla + Checks each INVARIANT in SPEC.cfg in a separate TLC run. TLC stops at the first rule that a state + breaks, and the separate runs find the rest. Each run also has the rule as a CONSTRAINT, so TLC + stops each path at its first state that breaks the rule, and a rule that stays broken reports + only the step that broke it. SPEC.cfg sits next to the spec and has SPECIFICATION and one + INVARIANT line per rule. One more run dumps the state graph, and then the command draws the + charts. TLC names each step after the innermost named action that takes it, so give each kind of + step its own named action. The command warns when steps carry the name of the whole next-state + action. + + --fixed FIXED.tla The model of the open fix. It is a copy of the spec with its own file and + module name, such as SpecFixed.tla with MODULE SpecFixed. The command checks + it the same way, with FIXED.cfg, or with SPEC.cfg when there is no FIXED.cfg. + The charts then mark each bad state fixed or still wrong. + --against AGREED.tla + The spec that SPEC.tla must follow, when SPEC.tla models the code. It needs + its own file and module name. The command checks it with AGREED.cfg, or with + SPEC.cfg when there is no AGREED.cfg. SPEC.tla may add variables, record + fields, and actions that AGREED.tla does not have. The comparison leaves them + out. Do not use it with --fixed. + --label TEXT The name of the --fixed or --against model in the charts and the stories. + Default: "the fix", or "the spec". + --map FILE The labels for each model state and step. Default: SPEC.map.mjs next to the + spec. When the file does not exist, the command writes one to fill in and + exits 2. Its comments say what each function gets.`; + +const FROM_CHART = `from-chart CHART.mmd + Writes NAME.tla, NAME.cfg, and NAME.map.mjs next to the chart. When NAME.tla already exists, it + writes none of them. Then it checks the spec as from-spec does, and compares the chart that the + spec draws with CHART.mmd. + + --module NAME The TLA+ module name. Default: the chart's file name in CamelCase, such as + NotesApp for notes-app.mmd. + + The chart is a Mermaid stateDiagram-v2. Each transition label is one action. For parallel + regions, split one top-level composite with --, and put each part in a named composite, such as + state Connection { ... }. Each region is one variable. A step moves every region whose current + state has a transition with the step's label, and the other regions stay where they are. Each + rule is one comment line: + %% never NAME: A & B -- the rule in one sentence + Each name after the colon is a state caption or a composite, and each comes from a different + region. A composite stands for every state in it. The command refuses final, choice, fork, and + history states, and regions below the top level.`; + +const COMMON = `Both commands + --expect-broken RULE + A rule that is known to break. Repeat it, or list rules with commas, as in + A,B. The command then exits 0 when exactly these rules break, and the --fixed + or --against model breaks no other rule. + --title TEXT The chart title. Each bug chart adds ", bug N". + --out DIR The output folder. Default: NAME-statechart next to the input. A run writes its + files in a temporary folder, and then replaces the whole output folder with + it. When runs overlap, the output folder has the files of the run that + finished last. The command does not use an output folder that has files that + it did not write. + --png Also render each chart to PNG with npx @mermaid-js/mermaid-cli. + -h, --help Print this help, and exit 0. + +Output + The command prints whether each rule holds, the chart's counts, and one story per bug. + from-chart also prints the comparison. The command writes these files in the output folder: + chart.mmd The overview. + bug-N.mmd Only the steps of bug N, numbered. Bugs are numbered by fewest reader steps, + then fewest model steps, then rule name, then cause. Bugs that only the + --fixed or --against model has come last. + bug-N-developer.mmd, bug-N-layered.mmd + The path of bug N through the developer's states, when the mapping exports + detail(). + stories.md One entry per bug: the rule, why it breaks, the shortest path in reader + steps, and what the --fixed or --against model does instead. + summary.json The counts behind the chart. + tlc/ The config and the TLC output of each run. When the overview has regions, + from-spec also writes tlc/regions-all.mmd, the regions with every arrow, and + tlc/regions-proof.mmd, every state and step of TLC's graph in one region. + compare-charts.mjs tlc/regions-all.mmd tlc/regions-proof.mmd repeats the + proof. + *.png Each chart as a PNG, with --png. A run without --png deletes them. + +Reading a chart + from-spec draws the overview as two parallel regions when every state reads "context: progress": + the contexts on one side, the progress on the other, both at the same time. It does so only when + that has fewer states, and when the regions combine back into exactly every state and step of + TLC's graph. Otherwise it puts the states of one context in a box named context, and prints why. + Regions draw only the arrows of the shortest way to each state, of each bug path, and of the + nearest step with each label. The script checks that these arrows make no step that TLC's graph + does not have. + An arrow from a box's border leaves from every state in the box. + In regions, a step leaves every region that has it. "[when A]" or "[unless A]" says which states + of the other region the step needs. A loop says the region may also stay. + Other words in [ ] come from the mapping's guard(), and "after N s" from its timer(). TLC does + not check them, and has no clock, so a reviewer checks them against the code. + Gray states work. Red states break a rule. With --fixed, red states stay wrong with the fix, and + green states are fixed by it or exist only with it. With --against, red states break a rule in + either model, and green states exist only in the spec. + When the mapping exports group(), the fill is the state's group, which the legend names, and the + status is a thick red or green border on top. tlc-to-chart.mjs --help tells more. + A state is bad when it breaks a rule, or when every path from the start to it passes a state + that breaks one. TLC stops each path at the first state that breaks the rule, so a rule that + stays broken colors only the states where it first breaks and the states after them that no + path without a bug reaches. + A thick blue border and numbered labels mark the bug. Mermaid state diagrams cannot color an + edge. + A label "with the fix, STEP" or "in the spec, STEP" is a step that only the other model takes. On + a bug's path it leaves the state before the first step that the other model shows the reader + differently, and ends where that model goes instead. "with the fix, STEP cannot happen" or "in + the spec, STEP cannot happen" loops on the state where that model stops the step. Outside + regions, two states have at most one arrow between them. Its lines are the numbered steps, then + SPEC.tla's steps, then the other model's steps, with a blank line between each group. + An edge lists every step that takes it, most common first. Words that the steps share at the + start are written once, as in "you tap Pay or Back". + +Regenerate + With --expect-broken, one script rebuilds every chart and stops on any change: + set -e + node statechart.mjs from-spec Code.tla --against Agreed.tla --expect-broken NoLostUpload --png + +TLC + TLA2TOOLS is the path to tla2tools.jar, and JAVA is the java binary (default: java, Java 11 or + later). Download tla2tools.jar from https://github.com/tlaplus/tlaplus/releases. When TLA2TOOLS + is not set, the command says where to get TLC and exits 2. + +Exit codes + 0 Every rule holds, or exactly the --expect-broken rules break. from-chart also draws CHART.mmd + back unchanged. + 1 Another rule is broken, an expected rule holds, or the chart that the spec draws is not + CHART.mmd. The output says which. + 2 The command cannot finish: a bad argument, a missing file, a chart or TLC output that it + cannot read, no TLC, a helper script that cannot start, an output folder with files that the + command did not write, an input file that changed during the run, a new mapping to fill in, + or a chart that --png cannot render. When a chart does not render, the command keeps the + files it wrote and exits 2, whatever the rules give. Otherwise, exit 2 does not change the + output folder, unless the command moved it aside and cannot put it back. Then the message + names the folder's new path. When TLC stops on an error, the message names TLC's output, in a + temporary folder that the command keeps.`; + +const HELP = { + "": `${usage([...Object.values(USAGE), "node statechart.mjs [from-spec|from-chart] --help"])} + +Draws a Mermaid statechart of what an app's user sees, from TLC's state graph of a TLA+ spec. The +help calls that user the reader. + +${FROM_SPEC} + +${FROM_CHART} + +${COMMON}`, + "from-spec": `${usage([USAGE["from-spec"]])}\n\n${FROM_SPEC}\n\n${COMMON}`, + "from-chart": `${usage([USAGE["from-chart"]])}\n\n${FROM_CHART}\n\n${COMMON}`, +}; + +const more = (cmd) => + `Run node statechart.mjs ${cmd ? `${cmd} ` : ""}--help for the flags, the output, and the exit codes.`; + +class CannotFinish extends Error {} +class TlcStopped extends CannotFinish {} + +const COMMANDS = { + "from-spec": { + flags: ["--map", "--fixed", "--against", "--label", "--expect-broken", "--title", "--out"], + run: fromSpec, + }, + "from-chart": { flags: ["--module", "--expect-broken", "--title", "--out"], run: fromChart }, +}; + +function parseArgs([command, ...rest]) { + if (command === "-h" || command === "--help") return { help: HELP[""] }; + if (!Object.hasOwn(COMMANDS, command)) + throw new CannotFinish(`${usage(Object.values(USAGE))}\n${more()}`); + const flags = { "expect-broken": new Set() }; + let input; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]; + if (a === "-h" || a === "--help") return { help: HELP[command] }; + if (a === "--png") flags.png = true; + else if (COMMANDS[command].flags.includes(a)) { + if (i + 1 === rest.length) throw new CannotFinish(`${a} needs a value. ${more(command)}`); + const name = a.slice(2); + if (flags[name] instanceof Set) for (const v of rest[++i].split(",")) flags[name].add(v); + else flags[name] = rest[++i]; + } else if (!a.startsWith("-") && input === undefined) input = a; + else throw new CannotFinish(`unknown argument ${a}. ${more(command)}`); + } + if (input === undefined) throw new CannotFinish(`${usage([USAGE[command]])}\n${more(command)}`); + return { command, input, flags }; +} + +const rel = (file) => path.relative(process.cwd(), file) || "."; +const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg"); + +function findTools() { + try { + return findTlc(); + } catch (e) { + throw new CannotFinish(e.message); + } +} + +function script(name, scriptArgs, about, { okExits = [0] } = {}) { + const r = spawnSync(process.execPath, [path.join(here, name), ...scriptArgs], { + encoding: "utf8", + }); + if (r.error) throw new CannotFinish(`cannot run ${name}: ${r.error.message}`); + if (okExits.includes(r.status)) return r; + const thrown = r.stderr.match(/^Error: (.*)$/m)?.[1]; + throw new CannotFinish(thrown ? `${rel(about)}: ${thrown}` : r.stderr.trim()); +} + +const KEYWORD = + /^(SPECIFICATION|INIT|NEXT|CONSTANTS?|INVARIANTS?|PROPERTY|PROPERTIES|SYMMETRY|VIEW|CONSTRAINTS?|ACTION_CONSTRAINTS?|CHECK_DEADLOCK|POSTCONDITION|ALIAS)$/; + +function splitRules(cfg) { + const words = cfg + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " ") + .split(/\s+/) + .filter(Boolean); + const kept = []; + const rules = []; + let section = ""; + for (const w of words) { + if (KEYWORD.test(w)) section = w; + if (!/^INVARIANTS?$/.test(section)) kept.push(KEYWORD.test(w) && kept.length ? `\n${w}` : w); + else if (w !== section && !rules.includes(w)) rules.push(w); + } + return { cfgWithoutRules: `${kept.join(" ")}\n`, rules }; +} + +function tlc(tools, spec, cfg, base, dump) { + fs.writeFileSync(`${base}.cfg`, cfg); + const metadir = fs.mkdtempSync(path.join(os.tmpdir(), "tlc-")); + // TLC writes the standard modules, such as Naturals.tla, to java.io.tmpdir under fixed names, + // so runs that share that folder can read a module while another run rewrites it. + const javaTmp = path.join(metadir, "tmp"); + fs.mkdirSync(javaTmp); + // -deadlock: a UI at rest has nothing left to do, which TLC would report as a deadlock. + const r = spawnSync( + tools.java, + [ + `-Djava.io.tmpdir=${javaTmp}`, + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + metadir, + "-deadlock", + "-continue", + ...(dump ? ["-dump", "dot,actionlabels", path.resolve(dump)] : []), + "-config", + path.resolve(`${base}.cfg`), + path.basename(spec), + ], + { cwd: path.dirname(path.resolve(spec)), encoding: "utf8", maxBuffer: 1 << 30 }, + ); + fs.rmSync(metadir, { recursive: true, force: true }); + if (r.error) throw new CannotFinish(`cannot run TLC: ${r.error.message}`); + fs.writeFileSync(`${base}.out`, r.stdout); + try { + checkTlcOutput(r.stdout, `${base}.out`); + } catch (e) { + throw new TlcStopped(e.message); + } + return r.stdout; +} + +function warnUnnamedSteps(spec, cfg, dot) { + const text = fs + .readFileSync(spec, "utf8") + .replace(/\(\*[^]*?\*\)/g, " ") + .replace(/\\\*.*/g, " "); + const specName = /^SPECIFICATION\s+(\w+)/m.exec(cfg)?.[1]; + const specBody = specName && new RegExp(`^${specName}\\s*==(.*(\\n[ \\t]+.*)*)`, "m").exec(text); + const next = + /^NEXT\s+(\w+)/m.exec(cfg)?.[1] ?? /\[\]\s*\[\s*(\w+)\s*\]_/.exec(specBody?.[1] ?? text)?.[1]; + const graph = parseDot(fs.readFileSync(dot, "utf8")); + const unnamed = [...graph.edges, ...graph.loops].filter((e) => e.action === next).length; + if (unnamed > 0) + console.error( + `${rel(spec)}: ${unnamed} ${unnamed === 1 ? "step has" : "steps have"} the name ${next}, the whole next-state action, because TLC names a step after the innermost named action that takes it. If the reader must tell these steps apart, give each part of ${next} its own name, as in Pay == ... and ${next} == Pay \\/ Charge.`, + ); +} + +function readModel(spec, cfgFile) { + if (!fs.existsSync(spec)) throw new CannotFinish(`${rel(spec)} does not exist`); + if (!fs.existsSync(cfgFile)) + throw new CannotFinish( + `${rel(cfgFile)} does not exist. It needs SPECIFICATION and one INVARIANT line per rule.`, + ); + return { spec, cfgFile, ...splitRules(fs.readFileSync(cfgFile, "utf8")) }; +} + +function readInput(spec, expected) { + const model = readModel(spec, cfgOf(spec)); + const unknown = [...expected].filter((r) => !model.rules.includes(r)); + if (unknown.length) + throw new CannotFinish( + `--expect-broken ${unknown.join(", ")}: not a rule in ${rel(model.cfgFile)}, whose rules are ${model.rules.join(", ") || "none"}.`, + ); + return model; +} + +function check(tools, { spec, cfgFile, cfgWithoutRules, rules }, work, expected) { + const name = path.basename(spec, ".tla"); + const tlcDir = path.join(work, "tlc"); + const dot = path.join(tlcDir, `${name}.dot`); + tlc(tools, spec, cfgWithoutRules, path.join(tlcDir, name), dot); + warnUnnamedSteps(spec, cfgWithoutRules, dot); + console.log(`TLC on ${path.basename(spec)}:`); + if (rules.length === 0) + console.log(` ${path.basename(cfgFile)} has no INVARIANT, so no rule is checked.`); + // With -continue TLC reports every bad state. The CONSTRAINT stops each path at its first bad + // state, so a rule that stays broken reports only the step that broke it, not every later state. + const traces = rules.map((rule) => { + const file = path.join(tlcDir, `${name}-${rule}`); + const cfg = `${cfgWithoutRules}INVARIANT ${rule}\nCONSTRAINT ${rule}\n`; + const broken = /^Error: Invariant /m.test(tlc(tools, spec, cfg, file)); + console.log( + ` ${rule} ${broken ? (expected.has(rule) ? "is broken (expected)" : "is broken") : "holds"}.`, + ); + return { file: `${file}.out`, rule, broken }; + }); + return { spec, dot, traces, broken: traces.some((t) => t.broken) }; +} + +function rulesAsExpected(now, other, expected) { + const brokenIn = (m) => (m?.traces ?? []).filter((t) => t.broken).map((t) => t.rule); + if (expected.size === 0) return !now.broken && !other?.broken; + const unexpected = [...new Set([...brokenIn(now), ...brokenIn(other)])].filter( + (r) => !expected.has(r), + ); + const holds = [...expected].filter((r) => !brokenIn(now).includes(r)); + if (unexpected.length) console.log(`\nBroken but not expected: ${unexpected.join(", ")}.`); + if (holds.length) console.log(`\nExpected to break but holds: ${holds.join(", ")}.`); + if (!unexpected.length && !holds.length) + console.log(`\nBroken as expected: ${[...expected].join(", ")}.`); + return !unexpected.length && !holds.length; +} + +const SCAFFOLD = (name, graph) => { + const vars = Object.keys([...graph.nodes.values()][0]?.vars ?? {}); + const actions = [...new Set([...graph.edges, ...graph.loops].map((e) => e.action))].sort(); + return `// Chart labels for ${name}, for statechart.mjs. Write labels in plain words, not variable or action names. +// v, before, and after map each variable to its value. Strings, numbers, and booleans keep their kind. +// Sequences, tuples, and sets become arrays, indexed from 0. Records and functions become objects. +// Variables: ${vars.join(", ")}. + +// The actions the reader takes. facts.settled is true when only these are enabled, and the overview +// keeps the shortest path to each settled state, plus every bug path. +export const readerActions = []; + +// The label for one model state. facts.enabled is the set of actions the state enables, +// including steps that change no variable. Write it as "context: progress", such as +// "Offline: upload queued": the chart puts the states of one context in one box. +// Do not claim a result that a pending step can still change, such as "upload failed" while a +// retry is still queued. +export function state(v, facts) { + return JSON.stringify(v); +} + +// The names of the two parallel regions, when the overview draws them: contexts, then progress. +// export const regionNames = ["Network", "What happened to the file"]; + +// The group of one model state, in words the reader sees, such as "Upload done". The chart fills +// the states of one group with one color, and the legend names the groups. TLC still decides +// which states break a rule, and draws them with a thick red border on top of the group color. +// groupColors gives a group a light color. Groups without one take a palette color. +// export function group(v, facts) {} +// export const groupColors = { "Upload done": "#d1f2eb" }; + +// The label for one step. A step that leaves the reader in the same state is not drawn, +// unless the reader takes it on a bug path. "" draws no label, and a numbered bug step shows the action. +const STEPS = { +${actions.map((a) => `\t${a}: ${JSON.stringify(a)},`).join("\n")} +}; +export function step(action, before, after) { + return STEPS[action] ?? action; +} + +// When the step can happen, in plain words, such as "offline or disk full". The chart shows +// "step [words]". "" adds nothing. +// export function guard(action, before, after) {} + +// For a timer step, how many seconds the code waits, from the constant in the code. The chart +// shows "after N s, step". Return nothing for other steps. +// export function timer(action, before, after) {} + +// Why a bad state is bad, in words a product manager reads, such as "the laptop went offline, and +// the app drops an upload it cannot send". No variable, function, or event names. +// Counterexamples that break one rule with one cause are one bug, so two bugs that end on the +// same screen stay apart only if their causes differ. Return null to group by the end state. +// export function cause(v, facts, invariant) {} + +// The developer's words for one model state. With it, each bug is also drawn through these states. +// export function detail(v, facts) {} + +// The legend's words for each status, in place of the defaults. Group entries keep their names. +// export const legend = { works: "Works on main", wrong: "Wrong on main", fixed: "Fixed in the open PR" }; +`; +}; + +async function draw({ now, other, map, work, out, title, comparing }) { + const shown = published(work, out); + const mapping = await import(pathToFileURL(path.resolve(map)).href); + const from = ["--dot", now.dot, "--map", map, "--spec", now.spec]; + from.push(...now.traces.flatMap((t) => ["--traces", t.file])); + if (other) { + from.push(`--${other.role}-dot`, other.dot, "--spec", other.spec); + from.push(...other.traces.flatMap((t) => [`--${other.role}-traces`, t.file])); + if (other.label !== undefined) from.push("--label", other.label); + } + if (comparing) from.push("--full-labels", "--flat"); + else if (mapping.readerActions?.length) from.push("--cover"); + const titled = (suffix) => (title ? ["--title", `${title}${suffix}`] : []); + + const files = { + overview: path.join(work, "chart.mmd"), + stories: path.join(work, "stories.md"), + summary: path.join(work, "summary.json"), + }; + const proof = path.join(work, "tlc", "regions-proof.mmd"); + const everyArrow = path.join(work, "tlc", "regions-all.mmd"); + script( + "tlc-to-chart.mjs", + [ + ...from, + ...(comparing ? [] : ["--regions", "--proof", proof, "--regions-all", everyArrow]), + ...titled(""), + "--highlight", + "0", + "--out", + files.overview, + "--story", + files.stories, + "--summary", + files.summary, + ], + map, + ); + const summary = JSON.parse(fs.readFileSync(files.summary, "utf8")); + const charts = [files.overview]; + for (const c of summary.classes) { + const views = typeof mapping.detail === "function" ? ["", "-developer", "-layered"] : [""]; + for (const view of views) { + const file = path.join(work, `bug-${c.n}${view}.mmd`); + const shape = [...(view ? ["--view", view.slice(1)] : []), "--path-only"]; + script( + "tlc-to-chart.mjs", + [...from, ...shape, ...titled(`, bug ${c.n}`), "--highlight", String(c.n), "--out", file], + map, + ); + charts.push(file); + } + } + + const { works, wrong, fixed: fixedCount } = summary.chartStatus; + const L = summary.second?.label; + console.log( + `\nChart: ${summary.chartStates} states and ${summary.chartTransitions} transitions from ${summary.modelStates} model states.`, + ); + const r = summary.regions; + if (r?.drawn) + console.log( + `It has 2 parallel regions, of ${r.states[0]} and ${r.states[1]} states. Proof: their ${r.all} arrows together make exactly the ${r.flat.states} states and ${r.flat.transitions} labeled steps of ${shown(proof)}, and the ${r.transitions} drawn arrows make only steps among them. ${shown(everyArrow)} has all ${r.all} arrows.`, + ); + else if (r) console.log(`It has no parallel regions, because ${r.why}.`); + const breaks = `${wrong} ${wrong === 1 ? "breaks" : "break"} a rule`; + console.log( + other?.role === "fixed" + ? `${works} work today, ${wrong} still wrong with ${L}, ${fixedCount} fixed or new with ${L}.\n` + : other?.role === "against" + ? `${works} work, ${breaks}, ${fixedCount} only in ${L}.\n` + : `${works} work, ${breaks}.\n`, + ); + if (summary.codeLike.length) + console.log( + `These labels or causes read like code. Write them in the reader's words in ${rel(map)}:\n${summary.codeLike.map((t) => ` ${t}`).join("\n")}\n`, + ); + console.log( + summary.classes.length + ? fs.readFileSync(files.stories, "utf8").trimEnd() + : "No rule is broken.", + ); + return { files, charts }; +} + +function render(charts, shown) { + const pngs = []; + for (const chart of charts) { + const png = chart.replace(/\.mmd$/, ".png"); + const r = spawnSync( + "npx", + ["-y", "@mermaid-js/mermaid-cli", "-i", chart, "-o", png, "-s", "2"], + { + encoding: "utf8", + }, + ); + if (r.status === 0) pngs.push(png); + else + console.error( + `could not render ${shown(chart)}: ${(r.stderr || r.error?.message || "").trim()}`, + ); + } + return pngs; +} + +function finish({ work, out, inputs }, { files, charts }, ok, png) { + const pngs = png ? render(charts, published(work, out)) : []; + publish(work, out, inputs); + const written = [...charts, ...pngs, files.stories, files.summary] + .map((f) => path.basename(f)) + .join(", "); + console.log(`\nWrote ${written} in ${rel(out)}.`); + if (png && pngs.length < charts.length) return 2; + return ok ? 0 : 1; +} + +const published = (work, out) => (file) => rel(path.join(out, path.relative(work, file))); + +const OWN = + /^((chart|bug-\d+(-developer|-layered)?)\.(mmd|png)|stories\.md|summary\.json|tlc|\.DS_Store)$/; + +function foreign(dir) { + try { + return fs + .readdirSync(dir) + .filter((f) => !OWN.test(f)) + .sort(); + } catch (e) { + if (e.code === "ENOENT") return []; + throw new CannotFinish(`cannot use ${rel(dir)} as the output folder: ${e.message}`); + } +} + +function refuseForeign(out, files = foreign(out)) { + const [first, ...others] = files; + if (first) + throw new CannotFinish( + `${rel(out)} has ${others.length ? `${others.length + 1} files that this command did not write, such as ${first}` : `a file that this command did not write: ${first}`}. A run puts its files in place of the whole output folder, so pass --out with a new folder.`, + ); +} + +function outDir(flagged, input, name) { + const out = flagged ?? path.join(path.dirname(input), `${name}-statechart`); + refuseForeign(out); + return out; +} + +const fingerprint = (file) => { + try { + const { ino, ctimeNs } = fs.statSync(file, { bigint: true }); + return `${ino} ${ctimeNs} ${fs.readFileSync(file, "base64")}`; + } catch { + return null; + } +}; + +function snapshot(files, inputs = new Map()) { + for (const f of files) if (f && !inputs.has(f)) inputs.set(f, fingerprint(f)); + return inputs; +} + +async function inWorkFolder(body) { + const work = fs.mkdtempSync(path.join(os.tmpdir(), "statechart-")); + fs.mkdirSync(path.join(work, "tlc")); + let keep = false; + try { + return await body(work); + } catch (e) { + keep = e instanceof TlcStopped; + throw e; + } finally { + if (!keep) fs.rmSync(work, { recursive: true, force: true }); + } +} + +const moved = (from, to, ...missed) => { + try { + fs.renameSync(from, to); + return true; + } catch (e) { + if (missed.includes(e.code)) return false; + throw e; + } +}; + +function publish(work, out, inputs) { + const parent = path.dirname(path.resolve(out)); + fs.mkdirSync(parent, { recursive: true }); + const next = fs.mkdtempSync(path.join(parent, `.${path.basename(out)}-`)); + const old = `${next}-old`; + let aside = false; + try { + fs.cpSync(work, next, { recursive: true }); + const changed = [...inputs].find(([f, was]) => fingerprint(f) !== was)?.[0]; + if (changed) + throw new CannotFinish( + `${rel(changed)} changed while this run read it, so the run left ${rel(out)} as it was. Run the command again.`, + ); + refuseForeign(out); + while (!moved(next, out, "ENOTEMPTY", "EEXIST")) { + if (aside) { + refuseForeign(out); + fs.rmSync(old, { recursive: true, force: true }); + aside = false; + } + aside = moved(out, old, "ENOENT"); + if (aside) refuseForeign(out, foreign(old)); + } + } catch (e) { + const back = !aside || putBack(old, out); + fs.rmSync(next, { recursive: true, force: true }); + const why = e instanceof CannotFinish ? e.message : `cannot replace ${rel(out)}: ${e.message}.`; + if (!back) + throw new CannotFinish( + `${why} This run moved the previous ${rel(out)} to ${rel(old)} and could not put it back.`, + ); + throw e instanceof CannotFinish + ? e + : new CannotFinish(`${why} The run left ${rel(out)} as it was.`); + } + if (aside) fs.rmSync(old, { recursive: true, force: true }); +} + +function putBack(old, out) { + try { + return moved(old, out, "ENOTEMPTY", "EEXIST"); + } catch { + return false; + } +} + +function secondModel(input, { fixed, against, label }) { + if (fixed && against) + throw new CannotFinish(`use --fixed or --against, not both. ${more("from-spec")}`); + if (!fixed && !against) { + if (label === undefined) return null; + throw new CannotFinish( + `--label names the --fixed or --against model; it needs one of them. ${more("from-spec")}`, + ); + } + const [role, spec] = fixed ? ["fixed", fixed] : ["against", against]; + const name = path.basename(input, ".tla"); + // TLC's output files are named after the spec, so a second model with the same name overwrites them. + if (path.basename(spec, ".tla") === name) + throw new CannotFinish( + `${rel(spec)} needs its own file and module name, such as ${name}${fixed ? "Fixed" : "Agreed"}.tla.`, + ); + const cfg = fs.existsSync(cfgOf(spec)) ? cfgOf(spec) : cfgOf(input); + return { role, label, model: readModel(spec, cfg) }; +} + +async function fromSpec(input, { "expect-broken": expected, ...flags }) { + const name = path.basename(input, ".tla"); + const map = flags.map ?? path.join(path.dirname(input), `${name}.map.mjs`); + const secondSpec = flags.fixed ?? flags.against; + const inputs = snapshot([input, cfgOf(input), map, secondSpec, secondSpec && cfgOf(secondSpec)]); + const second = secondModel(input, flags); + const tools = findTools(); + const model = readInput(input, expected); + const out = outDir(flags.out, input, name); + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + if (!fs.existsSync(map)) { + fs.writeFileSync(map, SCAFFOLD(name, parseDot(fs.readFileSync(now.dot, "utf8")))); + throw new CannotFinish( + `\nWrote ${rel(map)}. Fill in its labels, then run this command again.`, + ); + } + const other = second && { + role: second.role, + label: second.label, + ...check(tools, second.model, work, expected), + }; + const drawn = await draw({ now, other, map, work, out, title: flags.title }); + return finish({ work, out, inputs }, drawn, rulesAsExpected(now, other, expected), flags.png); + }); +} + +async function fromChart(input, { "expect-broken": expected, ...flags }) { + const name = + flags.module ?? + path + .basename(input, path.extname(input)) + .replace(/(^|[^A-Za-z0-9]+)([A-Za-z0-9])/g, (_, __, c) => c.toUpperCase()); + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) + throw new CannotFinish(`cannot make a TLA+ module name from ${input}; add --module NAME`); + const spec = path.join(path.dirname(input), `${name}.tla`); + const map = spec.replace(/\.tla$/, ".map.mjs"); + const inputs = snapshot([input]); + if (fs.existsSync(spec)) { + console.log(`Kept ${rel(spec)}. Delete it to write the spec again from ${rel(input)}.`); + } else { + script("chart-to-tla.mjs", [input, "--module", name], input); + console.log(`Wrote ${name}.tla, ${name}.cfg, and ${name}.map.mjs from ${rel(input)}.`); + } + snapshot([spec, cfgOf(spec), map], inputs); + const tools = findTools(); + const model = readInput(spec, expected); + const out = outDir(flags.out, input, name); + return inWorkFolder(async (work) => { + const now = check(tools, model, work, expected); + const drawn = await draw({ now, map, work, out, title: flags.title, comparing: true }); + const compared = script("compare-charts.mjs", [input, drawn.files.overview], input, { + okExits: [0, 1], + }); + if (compared.status === 0) console.log(`\nThe spec draws ${rel(input)} back unchanged.`); + else + console.log( + `\nThe spec does not draw ${rel(input)}:\n${compared.stdout.trimEnd().replace(/^/gm, " ")}`, + ); + return finish( + { work, out, inputs }, + drawn, + rulesAsExpected(now, null, expected) && compared.status === 0, + flags.png, + ); + }); +} + +try { + const { help, command, input, flags } = parseArgs(process.argv.slice(2)); + if (help) { + console.log(help); + process.exit(0); + } + process.exit(await COMMANDS[command].run(input, flags)); +} catch (e) { + if (!(e instanceof CannotFinish)) throw e; + console.error(e.message); + process.exit(2); +} diff --git a/skills/statechart/scripts/test/statechart.test.mjs b/skills/statechart/scripts/test/statechart.test.mjs new file mode 100644 index 0000000..a90c7e4 --- /dev/null +++ b/skills/statechart/scripts/test/statechart.test.mjs @@ -0,0 +1,2790 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { + checkTlcOutput, + findTlc, + parseDot, + parseMermaid, + parseTraces, + parseValue, + ruleSentences, + stateKey, +} from "../lib.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const scripts = dirname(here); +const fixture = (name) => readFileSync(join(here, name), "utf8"); +const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort(); +const node = (script, args, cwd, env = {}) => + spawnSync(process.execPath, [join(scripts, script), ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + timeout: 120_000, + }); + +test("a transition on a composite leaves from every state inside it", () => { + const { regions } = parseMermaid(fixture("upload.mmd")); + const main = regions.Main; + assert.deepEqual([...main.states].sort(), [ + "Nothing chosen", + "Upload failed", + "Uploaded", + "Uploading.Checking the file", + "Uploading.Sending the file", + ]); + assert.deepEqual([...main.initial], ["Nothing chosen"]); + assert.deepEqual(edges(main), [ + "Nothing chosen -> Uploading.Sending the file : you choose a file", + "Upload failed -> Uploading.Sending the file : you retry", + "Uploading.Checking the file -> Nothing chosen : you cancel", + "Uploading.Checking the file -> Upload failed : the network drops", + "Uploading.Checking the file -> Uploaded : the check passes", + "Uploading.Sending the file -> Nothing chosen : you cancel", + "Uploading.Sending the file -> Upload failed : the network drops", + "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives", + ]); +}); + +test("the deepest source wins when a composite and a state inside it share a label", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state Form { + [*] --> Typing + Typing --> Confirm : you press Escape + state Confirm + } + [*] --> Form + Form --> Closed : you press Escape + Confirm --> Closed : you press Escape`); + assert.deepEqual(edges(regions.Main), [ + "Form.Confirm -> Closed : you press Escape", + "Form.Typing -> Form.Confirm : you press Escape", + ]); +}); + +test("a top-level composite split by -- makes one region per part", () => { + const { regions, nevers } = parseMermaid(fixture("sync.mmd")); + assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]); + assert.deepEqual(edges(regions["Sync badge"]), [ + "Synced -> Waiting to sync : the network drops", + "Synced -> Waiting to sync : you edit a note", + "Waiting to sync -> Synced : the server confirms", + ]); + assert.deepEqual(nevers, [ + { + name: "NeverSyncedWhileOffline", + states: ["Offline", "Synced"], + sentence: 'You never see "Synced" while you are offline.', + }, + ]); +}); + +test("a rule's sentence is the comment lines right above it, and a state key ignores field order", () => { + const sentences = ruleSentences(`---- MODULE M ---- +\\* Not about a rule. + +\\* You never see "Saved" +\\* while the note is a draft. +NoFalseSaved == TRUE +Bare == TRUE +====`); + assert.deepEqual( + [...sentences], + [["NoFalseSaved", 'You never see "Saved" while the note is a draft.']], + ); + assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 })); +}); + +test("the legend is not part of the machine", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + state "Legend" as legend { + state "Works today" as legendWorks + } + [*] --> A + A --> B : go`); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); +}); + +test("the parser refuses charts it cannot read", () => { + assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/); + assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/); + assert.throws( + () => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"), + /no \[\*\] --> start/, + ); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A", + ), + /below the top level/, + ); + assert.throws( + () => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"), + /final state/, + ); + assert.throws( + () => + parseMermaid( + 'stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go', + ), + /two states have the caption "Something went wrong"/, + ); +}); + +test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + classDef wrong fill:#fdd + N : Nothing chosen + N : press Upload to start + [*] --> N + N:::wrong --> Done : you finish + Done --> Moving:::wrong + state A { + [*] --> X + X --> Y : go + } + Moving --> A : start + Y --> Z : next + A --> N : cancel`); + assert.deepEqual([...regions.Main.states].sort(), [ + "A.X", + "A.Y", + "Done", + "Moving", + "Nothing chosen", + "Z", + ]); + assert.deepEqual(edges(regions.Main), [ + "A.X -> A.Y : go", + "A.X -> Nothing chosen : cancel", + "A.Y -> Nothing chosen : cancel", + "A.Y -> Z : next", + "Done -> Moving : ", + "Moving -> A.X : start", + "Nothing chosen -> Done : you finish", + ]); +}); + +test("TLC values, states, graphs, and traces parse", () => { + assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] }); + assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 }); + const graph = parseDot(`strict digraph DiskGraph { +1 [label="/\\\\ x = 0",style = filled] +2 [label="/\\\\ x = 1"] +1 -> 2 [label="Inc",color="black",fontcolor="black"]; +1 -> 2 [label="Inc",color="black",fontcolor="black"]; +2 -> 2 [label="Stay"]; +}`); + assert.equal(graph.nodes.get("1").init, true); + assert.deepEqual(graph.nodes.get("2").vars, { x: 1 }); + assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]); + assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]); + assert.throws( + () => + parseDot( + 'strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}', + ), + /no action names/, + ); + const [trace] = parseTraces(`Error: Invariant Small is violated. +Error: The behavior up to this point is: +State 1: +x = 0 + +State 2: +x = 1 +`); + assert.equal(trace.invariant, "Small"); + assert.deepEqual(trace.steps, [ + { action: null, vars: { x: 0 } }, + { action: "Inc", vars: { x: 1 } }, + ]); +}); + +const done = "Model checking completed. No error has been found.\n"; +const evaluationError = `Error: Invariant NoLost is violated. +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Lost" + +Error: TLC threw an unexpected exception. +This was probably caused by an error in the spec or model. + +Error: The behavior up to this point is: +State 1: +s = "Idle" + +State 2: +s = "Saving" + +3 states generated, 3 distinct states found, 0 states left on queue. +`; + +test("a rule broken by a start state is a counterexample of one state", () => { + const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state: +/\\ badge = "Synced" +/\\ conn = "Offline" + +${done}`; + assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out")); + assert.deepEqual(parseTraces(text), [ + { + invariant: "NeverSyncedWhileOffline", + steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }], + }, + ]); +}); + +test("TLC output from a run that failed or stopped early is refused", () => { + assert.doesNotThrow(() => + checkTlcOutput( + `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n\n${done}`, + "ok.out", + ), + ); + assert.throws( + () => + checkTlcOutput( + "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n", + "cfg.out", + ), + /cfg\.out: TLC stopped on an error that is not a broken rule/, + ); + assert.throws( + () => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"), + /not a broken rule/, + ); + assert.throws( + () => checkTlcOutput(evaluationError, "eval.out"), + /TLC threw an unexpected exception/, + ); + assert.throws( + () => + checkTlcOutput( + "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: \nx = 0\n", + "stopped.out", + ), + /did not finish/, + ); + assert.deepEqual( + parseTraces(evaluationError).map((t) => t.invariant), + ["NoLost", null], + ); +}); + +test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "save.dot"), + `strict digraph DiskGraph { +1 [label="/\\\\ s = \\"Idle\\"",style = filled] +2 [label="/\\\\ s = \\"Saving\\""] +1 -> 2 [label="Tap"]; +2 -> 2 [label="Retry"]; +}`, + ); + writeFileSync( + join(dir, "save.map.mjs"), + `export const readerActions = ["Tap"]; +export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving"); +export const step = (action) => ({ Tap: "you tap Save" })[action] ?? ""; +`, + ); + const r = node( + "tlc-to-chart.mjs", + ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), [ + 'Badge says "Synced" -> Saving : you tap Save', + ]); +}); + +test("chart-to-tla writes one action per label and one invariant per never line", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + assert.match(spec, /^VARIABLES connection, syncBadge$/m); + assert.match( + spec, + /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m, + ); + assert.match( + spec, + /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m, + ); + assert.equal( + readFileSync(join(dir, "Sync.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/); +}); + +test("chart-to-tla refuses a never line that names no state", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /names "C", which is not a state/); +}); + +test("a never line names a state exactly, or every state inside a composite", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + `stateDiagram-v2 + state "Saving" as S + state "Saving..." as S2 + state Busy { + [*] --> Checking + Checking --> Sending : the check passes + } + [*] --> S + S --> S2 : you wait + S2 --> Busy : you send + Busy --> S : you cancel + %% never NoSaving: Saving -- You never see Saving. + %% never NoBusy: Busy -- You are never busy. +`, + ); + const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "C.tla"), "utf8"); + assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m); + assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m); + assert.match( + readFileSync(join(dir, "C.map.mjs"), "utf8"), + /^export const readerActions = \[\];$/m, + ); +}); + +const door = `stateDiagram-v2 + state Door { + [*] --> Locked + Locked --> Unlocked : tap unlock + Unlocked --> Locked : tap lock + -- + [*] --> Closed + Closed --> Open : open the door + Open --> Closed : close the door + } + [*] --> Door`; +const flatDoor = (extra = []) => + [ + "stateDiagram-v2", + ' state "Locked | Closed" as L_C', + ' state "Locked | Open" as L_O', + ' state "Unlocked | Closed" as U_C', + ' state "Unlocked | Open" as U_O', + " [*] --> L_C", + " L_C --> U_C : tap unlock", + " U_C --> L_C : tap lock", + " L_O --> U_O : tap unlock", + " L_C --> L_O : open the door", + " L_O --> L_C : close the door", + " U_C --> U_O : open the door", + " U_O --> U_C : close the door", + ...extra, + ].join("\n"); + +test("compare reads parallel regions as one machine, so a guard across regions shows", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + writeFileSync(join(dir, "guarded.mmd"), flatDoor()); + writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"])); + const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir); + assert.equal(differ.status, 1); + assert.match( + differ.stdout, + /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/, + ); + const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir); + assert.equal(same.stdout.trim(), "same", same.stdout); +}); + +const lamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : warm up [when the
power is on] + %% when On + Lit --> Dark : cool down [when the power is off] + %% when not On + } + } + %% never LitWhileOff: Off & Lit -- The bulb is never lit while the power is off. +`; + +test("a %% when line guards a step on the other region's states, and [words] are only its text", () => { + const { regions } = parseMermaid(lamp); + assert.deepEqual(regions.Bulb.transitions, [ + { from: "Dark", to: "Lit", label: "warm up", guard: { not: false, states: { Power: ["On"] } } }, + { + from: "Lit", + to: "Dark", + label: "cool down", + guard: { not: true, states: { Power: ["On"] } }, + }, + ]); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + writeFileSync( + join(dir, "flat.mmd"), + [ + "stateDiagram-v2", + " [*] --> OD", + ' state "Off | Dark" as OD', + ' state "On | Dark" as ND', + ' state "On | Lit" as NL', + ' state "Off | Lit" as OL', + " OD --> ND : flip the switch", + " ND --> OD : flip the switch", + " ND --> NL : warm up", + " NL --> OL : flip the switch", + " OL --> NL : flip the switch", + " OL --> OD : cool down", + ].join("\n"), + ); + const same = node("compare-charts.mjs", ["lamp.mmd", "flat.mmd"], dir); + assert.equal(same.stdout, "same\n", same.stderr); + const tla = node("chart-to-tla.mjs", ["lamp.mmd", "--module", "Lamp"], dir); + assert.equal(tla.status, 0, tla.stderr); + const spec = readFileSync(join(dir, "Lamp.tla"), "utf8"); + assert.match(spec, /\(bulb = "Dark" \/\\ power = "On" \/\\ bulb' = "Lit"\)/); + assert.match(spec, /\(bulb = "Lit" \/\\ ~\(power = "On"\) \/\\ bulb' = "Dark"\)/); + + for (const [from, to, why] of [ + [ + " %% when On\n", + " state Glow\n %% when On\n", + /must come right after a transition/, + ], + [ + "warm up [when the
power is on]", + "warm up", + /must end its label with the guard's words in \[ \]/, + ], + [" %% when On\n", " %% when Nope\n", /names Nope, which is not a state id/], + [" %% when On\n", " %% when Lit\n", /names Lit, which is in the step's own region/], + ]) + assert.throws(() => parseMermaid(lamp.replace(from, to)), why); + assert.throws( + () => + parseMermaid( + "stateDiagram-v2\n [*] --> A\n A --> B : go [when C]\n %% when C\n B --> C : on\n", + ), + /the chart has none/, + ); + assert.deepEqual( + edges(parseMermaid("stateDiagram-v2\n [*] --> A\n A --> B : go [user away]\n").regions.Main), + ["A -> B : go"], + ); +}); + +const switchLamp = `stateDiagram-v2 + state "Lamp" as lamp { + state "Power" as power { + [*] --> Off + Off --> On : flip the switch + On --> Off : flip the switch + } + -- + state "Bulb" as bulb { + [*] --> Dark + Dark --> Lit : flip the switch [when the power is off] + %% when Off + Lit --> Dark : flip the switch + } + } +`; + +test("a label with a guarded and an unguarded move in one region writes both moves", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("chart-to-tla.mjs", ["switch.mmd", "--module", "Switch"], dir); + assert.equal(r.status, 0, r.stderr); + const spec = readFileSync(join(dir, "Switch.tla"), "utf8"); + assert.equal( + spec.slice(spec.indexOf("\\* flip the switch"), spec.indexOf("\nNext ==")), + `\\* flip the switch +FlipTheSwitch == + /\\ (power \\in {"Off", "On"} \\/ (bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) + /\\ \\/ (power = "Off" /\\ power' = "On") + \\/ (power = "On" /\\ power' = "Off") + \\/ (power \\notin {"Off", "On"} /\\ UNCHANGED power) + /\\ \\/ (bulb = "Dark" /\\ power = "Off" /\\ bulb' = "Lit") + \\/ (bulb = "Lit" /\\ bulb' = "Dark") + \\/ (~((bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) /\\ UNCHANGED bulb) +`, + ); +}); + +test("compare reads a one-region chart as the machine its steps reach, without steps that change nothing", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const drawn = + "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy : start\n Busy --> Idle : finish\n"; + writeFileSync(join(dir, "drawn.mmd"), drawn); + writeFileSync(join(dir, "loop.mmd"), `${drawn} Idle --> Idle : refresh\n`); + writeFileSync(join(dir, "orphan.mmd"), `${drawn} Lost --> Idle : come back\n`); + const same = node("compare-charts.mjs", ["loop.mmd", "drawn.mmd"], dir); + assert.equal(same.status, 0); + assert.equal(same.stdout, "same\n"); + const orphan = node("compare-charts.mjs", ["orphan.mmd", "drawn.mmd"], dir); + assert.equal(orphan.status, 1); + assert.equal( + orphan.stdout, + "states the agreed chart's own steps never reach:\n Lost\n1 difference(s)\n", + ); +}); + +test("a -- outside a composite is refused, not read as one region", () => { + const split = + "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n"; + assert.throws(() => parseMermaid(split), /-- outside a composite/); + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "split.mmd"), split); + writeFileSync(join(dir, "door.mmd"), door); + const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir); + assert.notEqual(tla.status, 0); + assert.match(tla.stderr, /-- outside a composite/); + for (const pair of [ + ["split.mmd", "split.mmd"], + ["door.mmd", "split.mmd"], + ["split.mmd", "door.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/); + } +}); + +test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "agreed.mmd"), door); + for (const pair of [ + ["agreed.mmd", "missing.mmd"], + ["missing.mmd", "agreed.mmd"], + ]) { + const r = node("compare-charts.mjs", pair, dir); + assert.equal(r.status, 2, pair.join(" ")); + assert.match(r.stderr, /^missing\.mmd: ENOENT/); + } + for (const [extra, why] of [ + [" U_O --> L_O : 1. tap lock", /--highlight 0/], + [ + " %% second model (against): the spec", + "actual.mmd: it has a second model; draw ACTUAL without --fixed-dot or --against-dot\n", + ], + [ + ' state "Locked" as g1 {\n state "Closed" as L_C2\n }', + "actual.mmd: it has boxes; draw ACTUAL with --flat\n", + ], + ]) { + writeFileSync(join(dir, "actual.mmd"), flatDoor([extra])); + const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir); + assert.equal(r.status, 2, extra); + if (typeof why === "string") assert.equal(r.stderr, why); + else assert.match(r.stderr, why); + } +}); + +test("an edge lists every step, writes shared leading words once, and the chart renders at its natural width", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const nodes = [ + ["Home", 1], + ["Home", 2], + ["Home", 3], + ["Away", 1], + ["Away", 2], + ["Done", 1], + ].map( + ([s, n], i) => + `${i + 1} [label="/\\\\ s = \\"${s}\\"\\n/\\\\ n = ${n}"${i ? "" : ",style = filled"}]`, + ); + const steps = [ + "1 -> 4 TapPay", + "2 -> 5 TapBack", + "3 -> 4 TapHome", + "4 -> 1 OpenThread", + "5 -> 2 OpenApp", + "4 -> 6 Finish", + ]; + writeFileSync( + join(dir, "nav.dot"), + `strict digraph DiskGraph {\n${nodes.join("\n")}\n${steps.map((e) => e.replace(/ (\w+)$/, ' [label="$1"];')).join("\n")}\n}\n`, + ); + writeFileSync( + join(dir, "nav.map.mjs"), + `export const state = (v) => v.s; +export const step = (a) => ({ TapPay: "you tap Pay", TapBack: "you tap Back", TapHome: "you tap Home", OpenThread: "you open the thread", OpenApp: "you open the phone app", Finish: "" })[a]; +`, + ); + writeFileSync( + join(dir, "agreed.mmd"), + `stateDiagram-v2 + [*] --> Home + Home --> Away : you tap Pay + Home --> Away : you tap Back + Home --> Away : you tap Home + Away --> Home : you open the thread + Away --> Home : you open the phone app + Away --> Done +`, + ); + const draw = (...extra) => { + const r = node( + "tlc-to-chart.mjs", + [ + "--dot", + "nav.dot", + "--map", + "nav.map.mjs", + "--highlight", + "0", + "--out", + "chart.mmd", + ...extra, + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, "chart.mmd"), "utf8"); + }; + const chart = draw(); + assert.ok( + chart.startsWith( + "---\nconfig:\n markdownAutoWrap: false\n state:\n useMaxWidth: false\n---\nstateDiagram-v2\n", + ), + chart, + ); + const shortened = [ + "Away -> Done : ", + "Away -> Home : you open the thread or the phone app", + "Home -> Away : you tap Pay, Back, or Home", + ]; + assert.deepEqual(edges(parseMermaid(chart).regions.Main), shortened); + const crlf = chart.replace("---\n", "--- \n").replaceAll("\n", "\r\n"); + assert.deepEqual(edges(parseMermaid(crlf).regions.Main), shortened); + const refused = node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir); + assert.equal(refused.status, 2); + assert.match(refused.stderr, /draw ACTUAL with --full-labels/); + const full = draw("--full-labels"); + assert.match(full, /^stateDiagram-v2\n {2}%% labels in full\n/m); + assert.deepEqual(edges(parseMermaid(full).regions.Main), [ + "Away -> Done : ", + "Away -> Home : you open the thread or you open the phone app", + "Home -> Away : you tap Pay or you tap Back or you tap Home", + ]); + assert.equal(node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir).stdout, "same\n"); +}); + +const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release: + https://github.com/tlaplus/tlaplus/releases +Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later). +`; +const FROM_SPEC_USAGE = + "node statechart.mjs from-spec SPEC.tla [--fixed FIXED.tla | --against AGREED.tla] [--label TEXT] [--map FILE] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const FROM_CHART_USAGE = + "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]"; +const noTlc = { TLA2TOOLS: "", JAVA: "" }; + +test("statechart.mjs writes the spec next to the chart, keeps it on the next run, and stops without TLC", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + const first = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal(first.status, 2); + assert.equal( + first.stdout, + "Wrote NotesApp.tla, NotesApp.cfg, and NotesApp.map.mjs from notes-app.mmd.\n", + ); + assert.equal(first.stderr, NO_TLC); + assert.equal( + readFileSync(join(dir, "NotesApp.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n", + ); + const again = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc); + assert.equal( + again.stdout, + "Kept NotesApp.tla. Delete it to write the spec again from notes-app.mmd.\n", + ); + const spec = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, noTlc); + assert.equal(spec.status, 2); + assert.equal(spec.stderr, NO_TLC); + const missingJar = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, { + TLA2TOOLS: "/nonexistent/tla2tools.jar", + }); + assert.equal(missingJar.status, 2); + assert.equal( + missingJar.stderr, + "TLA2TOOLS is /nonexistent/tla2tools.jar, which does not exist.\n", + ); +}); + +test("statechart.mjs writes no output folder when it refuses the input", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT Paid\n"); + writeFileSync(join(dir, "cart.mmd"), "stateDiagram-v2\n [*] --> Cart\n Cart --> Paid : pay\n"); + for (const args of [ + ["from-spec", "Pya.tla"], + ["from-spec", "Pay.tla", "--expect-broken", "Unpaid"], + ["from-spec", "Pay.tla", "--fixed", "PayFixd.tla"], + ["from-chart", "cart.mmd", "--expect-broken", "Unpaid"], + ]) { + const r = node("statechart.mjs", args, dir, { TLA2TOOLS: join(dir, "tla2tools.jar") }); + assert.equal(r.status, 2, r.stderr); + } + assert.deepEqual( + readdirSync(dir).filter((f) => f.endsWith("-statechart")), + [], + ); +}); + +test("statechart.mjs does not use an output folder that has files that it did not write", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "tla2tools.jar"), ""); + writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n"); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + const env = { TLA2TOOLS: join(dir, "tla2tools.jar") }; + const cwd = node("statechart.mjs", ["from-spec", "Pay.tla", "--out", "."], dir, env); + assert.equal(cwd.status, 2); + assert.equal( + cwd.stderr, + ". has 3 files that this command did not write, such as Pay.cfg. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + const out = join(dir, "Pay-statechart"); + mkdirSync(join(out, "tlc"), { recursive: true }); + writeFileSync(join(out, "chart.mmd"), "kept\n"); + writeFileSync(join(out, ".DS_Store"), ""); + writeFileSync(join(out, "notes.md"), "mine\n"); + const notes = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, env); + assert.equal(notes.status, 2); + assert.equal( + notes.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(dir).sort(), [ + "Pay-statechart", + "Pay.cfg", + "Pay.tla", + "tla2tools.jar", + ]); + assert.deepEqual(readdirSync(out).sort(), [".DS_Store", "chart.mmd", "notes.md", "tlc"]); + assert.equal(readFileSync(join(out, "chart.mmd"), "utf8"), "kept\n"); +}); + +test("statechart.mjs exits 2 and names a helper script that cannot start", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd")); + writeFileSync(join(dir, "no-node.cjs"), 'process.execPath = "/nonexistent/node";\n'); + const r = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, { + NODE_OPTIONS: `--require ${join(dir, "no-node.cjs")}`, + }); + assert.equal(r.status, 2, r.stderr); + assert.match(r.stderr, /chart-to-tla\.mjs/); +}); + +test("statechart.mjs names the chart it refuses and the argument it does not know", () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "c.mmd"), + "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n", + ); + const refused = node("statechart.mjs", ["from-chart", "c.mmd"], dir); + assert.equal(refused.status, 2); + assert.equal(refused.stdout, ""); + assert.equal(refused.stderr, 'c.mmd: never line names "C", which is not a state in the chart\n'); + const unknown = node("statechart.mjs", ["from-chart", "c.mmd", "--fixed", "CFixed.tla"], dir); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "unknown argument --fixed. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const sameName = node("statechart.mjs", ["from-spec", "C.tla", "--fixed", "fix/C.tla"], dir); + assert.equal(sameName.status, 2); + assert.equal( + sameName.stderr, + "fix/C.tla needs its own file and module name, such as CFixed.tla.\n", + ); + const sameAgreed = node("statechart.mjs", ["from-spec", "C.tla", "--against", "a/C.tla"], dir); + assert.equal( + sameAgreed.stderr, + "a/C.tla needs its own file and module name, such as CAgreed.tla.\n", + ); + for (const [extra, stderr] of [ + [ + ["--fixed", "F.tla", "--against", "A.tla"], + "use --fixed or --against, not both. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + ["--label", "the new build"], + "--label names the --fixed or --against model; it needs one of them. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + ]) { + const r = node("statechart.mjs", ["from-spec", "C.tla", ...extra], dir); + assert.equal(r.status, 2, extra.join(" ")); + assert.equal(r.stderr, stderr); + } + const valueless = node("statechart.mjs", ["from-chart", "c.mmd", "--out"], dir); + assert.equal(valueless.status, 2); + assert.equal( + valueless.stderr, + "--out needs a value. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n", + ); + const bare = node("statechart.mjs", [], dir); + assert.equal(bare.status, 2); + assert.equal( + bare.stderr, + `Usage: ${FROM_SPEC_USAGE} + ${FROM_CHART_USAGE} +Run node statechart.mjs --help for the flags, the output, and the exit codes. +`, + ); +}); + +test("every script prints its help with -h or --help, and points at it for an argument it does not know", () => { + const help = { + "statechart.mjs": [[], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-spec": [["from-spec"], `Usage: ${FROM_SPEC_USAGE}`], + "statechart.mjs from-chart": [["from-chart", "notes-app.mmd"], `Usage: ${FROM_CHART_USAGE}`], + "chart-to-tla.mjs": [[], "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"], + "tlc-to-chart.mjs": [[], "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"], + "compare-charts.mjs": [[], "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"], + }; + for (const [name, [before, usage]] of Object.entries(help)) { + const script = name.split(" ")[0]; + for (const flag of ["-h", "--help"]) { + const r = node(script, [...before, flag], here); + assert.equal(r.status, 0, `${name} ${flag}`); + assert.equal(r.stderr, ""); + assert.equal(r.stdout.split("\n")[0], usage); + assert.match(r.stdout, /\nExit codes\n {2}0 .*\n(.*\n)* {2}1 .*\n(.*\n)* {2}2 /); + } + } + const top = node("statechart.mjs", ["--help"], here).stdout.split("\n"); + assert.equal(top[1], ` ${FROM_CHART_USAGE}`); + assert.equal(top[2], " node statechart.mjs [from-spec|from-chart] --help"); + for (const flag of [ + "--fixed FIXED.tla", + "--label TEXT", + "--map FILE", + "--module NAME", + "--title TEXT", + "--out DIR", + "--png", + "-h, --help", + ]) + assert.ok( + top.some((l) => l.startsWith(` ${flag} `)), + flag, + ); + for (const [script, args, stderr] of [ + [ + "statechart.mjs", + ["from-spec", "--nope"], + "unknown argument --nope. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n", + ], + [ + "chart-to-tla.mjs", + ["--nope"], + "unknown argument --nope. Run node chart-to-tla.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--nope"], + "unknown argument --nope. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--regions"], + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "tlc-to-chart.mjs", + ["--dot", "a.dot", "--map", "a.map.mjs", "--proof", "p.mmd"], + "--proof writes the regions proof; it needs --regions. Run node tlc-to-chart.mjs --help for the flags.\n", + ], + [ + "compare-charts.mjs", + ["--nope"], + "unknown argument --nope. Run node compare-charts.mjs --help for the flags.\n", + ], + ]) { + const r = node(script, args, here); + assert.equal(r.status, 2, script); + assert.equal(r.stdout, ""); + assert.equal(r.stderr, stderr); + } +}); + +const tools = (() => { + try { + return findTlc(); + } catch { + return null; + } +})(); +const needsTlc = { skip: tools ? false : "cannot find TLC" }; + +function tlc(dir, name) { + const r = spawnSync( + tools.java, + [ + "-cp", + tools.jar, + "tlc2.TLC", + "-metadir", + mkdtempSync(join(tmpdir(), "tlc-")), + "-deadlock", + "-continue", + "-dump", + "dot,actionlabels", + `${name}.dot`, + "-config", + `${name}.cfg`, + `${name}.tla`, + ], + { cwd: dir, encoding: "utf8" }, + ); + writeFileSync(join(dir, `${name}.out`), r.stdout); + return r; +} + +test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd")); + const r = node("statechart.mjs", ["from-chart", "upload.mmd"], dir); + assert.equal(r.status, 0, r.stderr); + assert.equal( + r.stdout, + `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd. +TLC on Upload.tla: + TypeOK holds. + +Chart: 5 states and 8 transitions from 5 model states. +5 work, 0 break a rule. + +No rule is broken. + +The spec draws upload.mmd back unchanged. + +Wrote chart.mmd, stories.md, summary.json in Upload-statechart. +`, + ); + assert.equal(r.stderr, ""); +}); + +test("TLC reads a label with a guarded and an unguarded move in one region", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "switch.mmd"), switchLamp); + const r = node("statechart.mjs", ["from-chart", "switch.mmd"], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws switch\.mmd back unchanged\.\n/); +}); + +const syncStory = `## 1. Offline | Synced + +Rule broken: You never see "Synced" while you are offline. +This is the only way to get here in the model. + +0. Start: Online | Synced +1. The network drops. Now: Offline | Waiting to sync +2. The server confirms. Now: Offline | Synced +`; + +test("from-chart finds the design bug, and from-spec with the fix marks it fixed", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const today = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(today.status, 1, today.stderr); + assert.equal( + today.stdout, + `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. + +Chart: 4 states and 8 transitions from 4 model states. +3 work, 1 breaks a rule. + +${syncStory} +The spec draws sync.mmd back unchanged. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Sync-statechart", "bug-1.mmd"), "utf8"), + /: 2\. the server confirms\n/, + ); + + const spec = readFileSync(join(dir, "Sync.tla"), "utf8"); + const guarded = spec + .replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n') + .replace("MODULE Sync", "MODULE SyncFixed"); + assert.notEqual(guarded, spec); + writeFileSync(join(dir, "SyncFixed.tla"), guarded); + const fixed = node("statechart.mjs", ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], dir); + assert.equal(fixed.status, 1, fixed.stderr); + assert.equal( + fixed.stdout, + `TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline is broken. +TLC on SyncFixed.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 4 states and 9 transitions from 4 model states. +3 work today, 0 still wrong with the fix, 1 fixed or new with the fix. + +${syncStory} +With the fix, step 2 (the server confirms) cannot happen, so you stay at: Offline | Waiting to sync. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + const summary = JSON.parse(readFileSync(join(dir, "Sync-statechart", "summary.json"), "utf8")); + assert.deepEqual(summary.chartStatus, { works: 3, wrong: 0, fixed: 1 }); + assert.equal(summary.classes[0].fix, "Offline | Waiting to sync"); + + const map = readFileSync(join(dir, "Sync.map.mjs"), "utf8"); + writeFileSync(join(dir, "Sync.map.mjs"), `${map}export const group = () => "Your notes";\n`); + const grouped = node( + "statechart.mjs", + ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], + dir, + ); + assert.equal(grouped.status, 1, grouped.stderr); + const lines = readFileSync(join(dir, "Sync-statechart", "chart.mmd"), "utf8").split("\n"); + assert.deepEqual( + lines.filter((l) => /^ {2}(classDef|class) |legend/.test(l)), + [ + " classDef plainFixed fill:#f4f6f7,stroke:#1e8449,stroke-width:4px,color:#145a32", + " classDef group0 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group0Fixed fill:#d6eaf8,stroke:#1e8449,stroke-width:4px,color:#145a32", + ' state "Legend" as legend {', + ' state "Your notes" as legendGroup0', + ' state "Fixed or new with the fix" as legendFixed', + " class legendGroup0 group0", + " class legendFixed plainFixed", + " class s0 group0Fixed", + " class s1 group0", + " class s2 group0", + " class s3 group0", + ], + ); + writeFileSync(join(dir, "Sync.map.mjs"), map); + + writeFileSync(join(dir, "Sync.tla"), guarded.replace("MODULE SyncFixed", "MODULE Sync")); + const coded = node("statechart.mjs", ["from-chart", "sync.mmd"], dir); + assert.equal(coded.status, 1, coded.stderr); + assert.equal( + coded.stdout, + `Kept Sync.tla. Delete it to write the spec again from sync.mmd. +TLC on Sync.tla: + TypeOK holds. + NeverSyncedWhileOffline holds. + +Chart: 3 states and 5 transitions from 3 model states. +3 work, 0 break a rule. + +No rule is broken. + +The spec does not draw sync.mmd: + states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them): + Offline | Synced + transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step): + Offline | Synced --> Offline | Waiting to sync : the network drops + Offline | Synced --> Offline | Waiting to sync : you edit a note + Offline | Synced --> Online | Synced : the network returns + Offline | Waiting to sync --> Offline | Synced : the server confirms + 5 difference(s) + +Wrote chart.mmd, stories.md, summary.json in Sync-statechart. +`, + ); + assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [ + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); +}); + +test( + "--png exits 2 unless every chart renders, even with --expect-broken, and keeps the files it wrote", + needsTlc, + () => { + const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline", "--png"]; + const bin = mkdtempSync(join(tmpdir(), "fake-npx-")); + const launch = "Error: Failed to launch the browser process!"; + writeFileSync( + join(bin, "npx"), + `#!/bin/sh +while [ $# -gt 0 ]; do + case "$1" in -i) chart=$2 ;; -o) png=$2 ;; esac + shift +done +for c in $UNRENDERED; do + case "$chart" in */$c.mmd) echo "${launch}" >&2; exit 1 ;; esac +done +echo png > "$png" +`, + { mode: 0o755 }, + ); + for (const [unrendered, status, written] of [ + ["chart bug-1", 2, ["chart.mmd", "bug-1.mmd"]], + ["bug-1", 2, ["chart.mmd", "bug-1.mmd", "chart.png"]], + ["", 0, ["chart.mmd", "bug-1.mmd", "chart.png", "bug-1.png"]], + ]) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd")); + const r = node("statechart.mjs", args, dir, { + PATH: `${bin}${delimiter}${process.env.PATH}`, + UNRENDERED: unrendered, + }); + assert.equal(r.status, status, `${unrendered}: ${r.stdout}${r.stderr}`); + assert.equal( + r.stderr, + unrendered + .split(" ") + .filter(Boolean) + .map((c) => `could not render Sync-statechart/${c}.mmd: ${launch}\n`) + .join(""), + ); + assert.match(r.stdout, /\nBroken as expected: NeverSyncedWhileOffline\.\n/); + assert.equal( + r.stdout.split("\n").at(-2), + `Wrote ${[...written, "stories.md", "summary.json"].join(", ")} in Sync-statechart.`, + ); + assert.deepEqual( + readdirSync(join(dir, "Sync-statechart")).sort(), + [...written, "stories.md", "summary.json", "tlc"].sort(), + ); + } + }, +); + +const pay = (name, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges +vars == <> +Init == screen = "Cart" /\\ charges = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED charges +Charge == ${charge} +Refund == charges = 2 /\\ charges' = 1 /\\ screen' = "Refunded" +Next == Pay \\/ Charge \\/ Refund +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const payMap = `export const readerActions = ["Pay"]; +export const state = (v) => v.screen + ["", ", charged", ", charged twice"][v.charges]; +export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you", Refund: "the bank refunds you" })[action] ?? ""; +export const cause = () => "each charge request charges again"; +`; + +test( + "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"; + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "PayFixed.tla"), + pay( + "PayFixed", + `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`, + ), + ); + writeFileSync(join(dir, "Pay.cfg"), cfg); + writeFileSync(join(dir, "PayFixed.cfg"), cfg); + writeFileSync(join(dir, "pay.map.mjs"), payMap); + assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/); + assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/); + + const statusOf = (chart) => { + const names = new Map( + [...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [ + m[2], + m[1].replace(//g, " "), + ]), + ); + return Object.fromEntries( + [...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]]), + ); + }; + const args = [ + "--dot", + "Pay.dot", + "--map", + "pay.map.mjs", + "--traces", + "Pay.out", + "--highlight", + "0", + "--out", + "chart.mmd", + ]; + assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0); + assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), { + Cart: "works", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "wrong", + "Refunded, charged": "wrong", + }); + + const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"]; + assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0); + const chart = readFileSync(join(dir, "chart.mmd"), "utf8"); + assert.deepEqual(statusOf(chart), { + Cart: "works", + "Paid, charged": "fixed", + Paying: "works", + "Paying, charged": "works", + "Paying, charged twice": "fixed", + "Refunded, charged": "fixed", + }); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works today", "Fixed or new with the fix"], + ); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/m); + const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) => + t.label.startsWith("with the fix, "), + ); + assert.deepEqual( + fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`), + ["Paying, charged --> Paid, charged : with the fix, the bank charges you"], + ); + }, +); + +test( + "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + pay( + "Pay", + `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`, + ), + ); + writeFileSync( + join(dir, "Pay.cfg"), + "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n", + ); + const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(first.status, 2); + assert.equal(first.stdout, "TLC on Pay.tla:\n NoDoubleCharge is broken.\n"); + assert.equal( + first.stderr, + "\nWrote Pay.map.mjs. Fill in its labels, then run this command again.\n", + ); + const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8"); + assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m); + assert.match( + scaffold, + /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m, + ); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); + + writeFileSync( + join(dir, "Pay.map.mjs"), + `${payMap}export const detail = (v) => \`charges = \${v.charges}\`;\n`, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + assert.equal(r.status, 1, r.stderr); + assert.equal( + r.stdout, + `TLC on Pay.tla: + NoDoubleCharge is broken. + +Chart: 5 states and 4 transitions from 5 model states. +3 work, 2 break a rule. + +## 1. Paying, charged twice + +Rule broken: \`NoDoubleCharge\` +Why: each charge request charges again. +This is the only way to get here in the model. + +0. Start: Cart +1. You pay. Now: Paying +2. The bank charges you. Now: Paying, charged +3. The bank charges you. Now: Paying, charged twice + +Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, summary.json in Pay-statechart. +`, + ); + assert.match( + readFileSync(join(dir, "Pay-statechart", "bug-1-layered.mmd"), "utf8"), + /state "Paying, charged twice" as g\d \{\n {4}state "charges = 2" as s\d\n/, + ); + assert.deepEqual(readdirSync(join(dir, "Pay-statechart")).sort(), [ + "bug-1-developer.mmd", + "bug-1-layered.mmd", + "bug-1.mmd", + "chart.mmd", + "stories.md", + "summary.json", + "tlc", + ]); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"), + "SPECIFICATION Spec\n", + ); + assert.equal( + readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"), + "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n", + ); + }, +); + +test("a rule broken by a start state marks that state wrong", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Start.tla"), + `---- MODULE Start ---- +VARIABLES conn, badge +vars == <> +Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced" +Drop == conn = "Online" /\\ conn' = "Offline" /\\ badge' = "Waiting" +Next == Drop +Spec == Init /\\ [][Next]_vars +NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced") +==== +`, + ); + writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n"); + writeFileSync( + join(dir, "start.map.mjs"), + 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n', + ); + assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/); + const args = [ + "--dot", + "Start.dot", + "--map", + "start.map.mjs", + "--traces", + "Start.out", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ]; + const r = node("tlc-to-chart.mjs", args, dir); + assert.equal(r.status, 0, r.stderr); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.end, c.readerSteps]), + [["Offline | Synced", 0]], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 }); +}); + +const edit = (name, fixed) => `---- MODULE ${name} ---- +VARIABLES s, flashed +vars == <> +Init == s = "Idle" /\\ flashed = FALSE +Tap == s = "Idle" /\\ s' = "Saving" /\\ UNCHANGED flashed +Lose == s = "Saving" /\\ s' = "Lost edit" /\\ UNCHANGED flashed +Flash == s = "Saving" /\\ s' = "Flash" /\\ flashed' = ${fixed ? "TRUE" : "FALSE"} +Back == s \\in {"Lost edit", "Flash"} /\\ s' = "Idle" /\\ flashed' = FALSE +Next == Tap \\/ Flash \\/ Back${fixed ? "" : " \\/ Lose"} +Spec == Init /\\ [][Next]_vars +NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed) +==== +`; + +test( + "a bug that only the fixed model has is numbered after the current bugs and drawn wrong", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n"; + for (const [name, fixed] of [ + ["Edit", false], + ["EditFixed", true], + ]) { + writeFileSync(join(dir, `${name}.tla`), edit(name, fixed)); + writeFileSync(join(dir, `${name}.cfg`), cfg); + assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/); + } + writeFileSync( + join(dir, "edit.map.mjs"), + 'export const readerActions = ["Tap"];\nexport const state = (v) => v.s;\nexport const step = (a) => ({ Tap: "you tap Save", Lose: "the save fails", Flash: "the screen flashes", Back: "you go back" })[a] ?? "";\n', + ); + const args = [ + "--dot", + "Edit.dot", + "--map", + "edit.map.mjs", + "--traces", + "Edit.out", + "--fixed-dot", + "EditFixed.dot", + "--fixed-traces", + "EditFixed.out", + ]; + const r = node( + "tlc-to-chart.mjs", + [ + ...args, + "--cover", + "--highlight", + "0", + "--story", + "story.md", + "--summary", + "summary.json", + "--out", + "chart.mmd", + ], + dir, + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/); + const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8")); + assert.deepEqual( + summary.classes.map((c) => [c.n, c.end, c.fix]), + [ + [1, "Lost edit", "Saving"], + [2, "Flash", "new with the fix"], + ], + ); + assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 }); + assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m); + const two = node( + "tlc-to-chart.mjs", + [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"], + dir, + ); + assert.equal(two.status, 0, two.stderr); + assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/); + }, +); + +function flatEdges(mmd) { + const names = new Map(); + const members = new Map(); + let box = null; + for (const line of mmd.split("\n")) { + const opens = /^ {2}state "([^"]+)" as (g\d+) \{$/.exec(line); + const state = /^ +state "([^"]+)" as (s\d+)$/.exec(line); + const said = (s) => s.replaceAll("
", " "); + if (opens) { + box = { id: opens[2], name: said(opens[1]) }; + members.set(box.id, []); + } else if (line === " }") box = null; + else if (state) { + names.set(state[2], box ? `${box.name}: ${said(state[1])}` : said(state[1])); + if (box) members.get(box.id).push(state[2]); + } + } + return [...mmd.matchAll(/^ {2}([gs]\d+) --> (s\d+) : (.*)$/gm)] + .flatMap(([, from, to, label]) => + (members.get(from) ?? [from]).map((f) => `${names.get(f)} --> ${names.get(to)} : ${label}`), + ) + .sort(); +} + +test( + "states of one context sit in one box, and an arrow from the box's border stands for the same step from each", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Window.tla"), + `---- MODULE Window ---- +VARIABLES where, reply +vars == <> +Init == where = "Starting" /\\ reply = "none" +Boot == where = "Starting" /\\ where' = "Chat open" /\\ UNCHANGED reply +Ask == where = "Chat open" /\\ reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Close == where = "Chat open" /\\ where' = "Window closed" /\\ UNCHANGED reply +Open == where = "Window closed" /\\ where' = "Chat open" /\\ reply' = "none" +Next == Boot \\/ Ask \\/ Answer \\/ Close \\/ Open +Spec == Init /\\ [][Next]_vars +NoReplyWhileClosed == ~(where = "Window closed" /\\ reply = "shown") +==== +`, + ); + writeFileSync(join(dir, "Window.cfg"), "SPECIFICATION Spec\nINVARIANT NoReplyWhileClosed\n"); + writeFileSync( + join(dir, "window.map.mjs"), + `const R = { none: "no question", pending: "reply on its way", shown: "reply shown" }; +export const state = (v) => \`\${v.where}: \${v.where === "Starting" ? "loading" : R[v.reply]}\`; +export const step = (a) => ({ Boot: "the app starts", Ask: "you ask", Answer: "the reply arrives", Close: "you close the window", Open: "you open the window" })[a]; +`, + ); + assert.match(tlc(dir, "Window").stdout, /Invariant NoReplyWhileClosed is violated/); + const args = ["--dot", "Window.dot", "--map", "window.map.mjs", "--traces", "Window.out"]; + const draw = (extra, out) => { + const r = node("tlc-to-chart.mjs", [...args, ...extra, "--out", out], dir); + assert.equal(r.status, 0, r.stderr); + return readFileSync(join(dir, out), "utf8"); + }; + const boxed = draw([], "boxed.mmd"); + const flat = draw(["--flat"], "flat.mmd"); + + assert.match( + boxed, + /^ {2}state "Chat open" as g0 \{\n {4}state "no question" as s0\n {4}state "reply on its way" as s1\n {4}state "reply shown" as s2\n {2}\}\n {2}state "Window closed" as g1 \{\n {4}state "no question" as s3\n {4}state "reply on its way" as s4\n {4}state "reply shown" as s5\n {2}\}\n {2}state "Starting: loading" as s6\n/m, + ); + assert.deepEqual( + boxed.split("\n").filter((l) => /you open the window/.test(l)), + [" g1 --> s0 : you open the window"], + ); + assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3); + assert.doesNotMatch(flat, /as g\d+ \{/); + assert.deepEqual(flatEdges(boxed), flatEdges(flat)); + assert.match(boxed, /^ {2}class s5 wrongPath$/m); + assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m); + assert.match(boxed, /state "Breaks a rule" as legendWrong/); + }, +); + +const save = (name, code) => `---- MODULE ${name} ---- +VARIABLES screen, note${code ? ", retries" : ""} +vars == <> +Init == screen = "Editing" /\\ note = [text |-> "draft"${code ? `, rev |-> 0] /\\ retries = 0` : "]"} +Save == screen = "Editing" /\\ screen' = "Saving" /\\ UNCHANGED <> +Confirm == screen = "Saving" /\\ screen' = "Saved" /\\ note' = [text |-> "stored"${code ? `, rev |-> 1] /\\ UNCHANGED retries` : "]"} +Edit == screen = "Saved" /\\ screen' = "Editing" /\\ note' = [note EXCEPT !.text = "draft"]${code ? " /\\ retries' = 0" : ""} +${ + code + ? `Retry == screen = "Saving" /\\ retries = 0 /\\ retries' = 1 /\\ UNCHANGED <> +TimeOut == screen = "Saving" /\\ retries = 1 /\\ screen' = "Saved" /\\ UNCHANGED <> +Next == Save \\/ Retry \\/ Confirm \\/ TimeOut \\/ Edit` + : "Next == Save \\/ Confirm \\/ Edit" +} +Spec == Init /\\ [][Next]_vars +TypeOK == screen \\in {"Editing", "Saving", "Saved"} +\\* You never see "Saved" while the note is still a draft. +NoFalseSaved == ~(screen = "Saved" /\\ note.text = "draft") +==== +`; + +test( + "from-spec --against compares the code with the spec on the parts both have, and --expect-broken sets the exit code", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "SaveCode.tla"), save("SaveCode", true)); + writeFileSync(join(dir, "SaveAgreed.tla"), save("SaveAgreed", false)); + writeFileSync( + join(dir, "SaveCode.cfg"), + "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\n", + ); + writeFileSync( + join(dir, "SaveCode.map.mjs"), + `export const readerActions = ["Save", "Edit"]; +export const state = (v) => \`\${v.screen}, \${v.note.text}\`; +export const step = (a) => ({ Save: "you press Save", Retry: "the app tries again", Confirm: "the server stores the note", TimeOut: "the save times out", Edit: "you edit the note" })[a] ?? ""; +export const cause = () => "the app stops after one retry and shows Saved anyway"; +`, + ); + const run = (...extra) => + node( + "statechart.mjs", + [ + "from-spec", + "SaveCode.tla", + "--against", + "SaveAgreed.tla", + "--label", + "the agreed chart", + ...extra, + ], + dir, + ); + + const unknown = run("--expect-broken", "NoLostNote"); + assert.equal(unknown.status, 2); + assert.equal( + unknown.stderr, + "--expect-broken NoLostNote: not a rule in SaveCode.cfg, whose rules are TypeOK, NoFalseSaved.\n", + ); + + const expected = run("--expect-broken", "NoFalseSaved"); + assert.equal(expected.status, 0, expected.stderr); + assert.equal( + expected.stdout, + `TLC on SaveCode.tla: + TypeOK holds. + NoFalseSaved is broken (expected). +TLC on SaveAgreed.tla: + TypeOK holds. + NoFalseSaved holds. + +Chart: 4 states and 4 transitions from 10 model states. +3 work, 1 breaks a rule, 0 only in the agreed chart. + +## 1. Saved, draft + +Rule broken: You never see "Saved" while the note is still a draft. +Why: the app stops after one retry and shows Saved anyway. +There are 2 ways to get here in the model. This is the shortest. + +0. Start: Editing, draft +1. You press Save. Now: Saving, draft +2. The save times out. Now: Saved, draft + +In the agreed chart, step 2 (the save times out) cannot happen, so you stay at: Saving, draft. + +Broken as expected: NoFalseSaved. + +Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart. +`, + ); + const out = join(dir, "SaveCode-statechart"); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m); + assert.deepEqual( + [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]), + ["Works", "Breaks a rule"], + ); + assert.match( + chart, + /^ {2}s3 --> s3 : in the agreed chart, the savetimes out cannot happen$/m, + ); + const bug = readFileSync(join(out, "bug-1.mmd"), "utf8"); + assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [ + "Editing, draft", + "Saved, draft", + "Saving, draft", + ]); + const summary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8")); + assert.deepEqual(summary.second.role, "against"); + assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]); + assert.equal(summary.classes[0].against, "Saving, draft"); + assert.deepEqual(summary.codeLike, []); + + const unexpected = run(); + assert.equal(unexpected.status, 1, unexpected.stderr); + const holds = run("--expect-broken", "TypeOK"); + assert.equal(holds.status, 1, holds.stderr); + assert.match( + holds.stdout, + /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/, + ); + const listed = run("--expect-broken", "NoFalseSaved,TypeOK"); + assert.equal(listed.status, 1, listed.stderr); + assert.match(listed.stdout, /\nExpected to break but holds: TypeOK\.\n/); + assert.doesNotMatch(listed.stdout, /Broken but not expected/); + + writeFileSync( + join(dir, "code.map.mjs"), + readFileSync(join(out, "../SaveCode.map.mjs"), "utf8").replace( + '"the app stops after one retry and shows Saved anyway"', + '"retryCount hit maxRetries"', + ), + ); + const coded = run("--map", "code.map.mjs", "--expect-broken", "NoFalseSaved"); + assert.equal(coded.status, 0, coded.stderr); + assert.match( + coded.stdout, + /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/, + ); + }, +); + +test( + "from-chart checks a chart whose regions guard each other, and draws it back unchanged", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "lamp.mmd"), lamp); + const r = node( + "statechart.mjs", + ["from-chart", "lamp.mmd", "--expect-broken", "LitWhileOff"], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nThe spec draws lamp\.mmd back unchanged\.\n/); + }, +); + +const doorSpec = `---- MODULE Door ---- +VARIABLES where, reply +vars == <> +Init == where = "Open" /\\ reply = "none" +Ask == reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where +Answer == where = "Open" /\\ reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where +Drop == where = "Closed" /\\ reply = "pending" /\\ reply' = "lost" /\\ UNCHANGED where +Close == where = "Open" /\\ where' = "Closed" /\\ UNCHANGED reply +Reopen == where = "Closed" /\\ where' = "Open" /\\ (reply' = reply \\/ (reply = "pending" /\\ reply' = "shown")) +Next == Ask \\/ Answer \\/ Drop \\/ Close \\/ Reopen +Spec == Init /\\ [][Next]_vars +NoLostReply == reply # "lost" +NoShownWhileClosed == ~(where = "Closed" /\\ reply = "shown") +==== +`; + +const doorMap = `const W = { Open: "Window open", Closed: "Window closed" }; +const R = { none: "no question", pending: "reply on its way", shown: "reply shown", lost: "reply lost" }; +export const regionNames = ["Where you are", "Your reply"]; +export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`; +export const step = (a) => ({ Ask: "you ask", Answer: "the reply arrives", Drop: "the app drops the reply", Close: "you close the window", Reopen: "you open the window" })[a]; +`; + +function runDoor(map) { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.map.mjs"), map); + const out = join(dir, "Door-statechart"); + const read = (f) => readFileSync(join(out, f), "utf8").replaceAll("
", " "); + return (rule) => { + writeFileSync(join(dir, "Door.cfg"), `SPECIFICATION Spec\nINVARIANT ${rule}\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla", "--expect-broken", rule], dir); + assert.equal(r.status, 0, r.stdout + r.stderr); + const every = join(out, "tlc", "regions-all.mmd"); + return { + stdout: r.stdout, + chart: read("chart.mmd"), + every: existsSync(every) ? read("tlc/regions-all.mmd") : null, + bug: read("bug-1.mmd"), + out, + }; + }; +} + +test( + "from-spec draws parallel regions only when they combine back into TLC's graph, and boxes otherwise", + needsTlc, + () => { + const run = runDoor(doorMap); + const proved = run("NoLostReply"); + assert.match( + proved.stdout, + /\nChart: 6 states and 5 transitions from 8 model states\.\nIt has 2 parallel regions, of 2 and 4 states\. Proof: their 7 arrows together make exactly the 8 states and 13 labeled steps of Door-statechart\/tlc\/regions-proof\.mmd, and the 5 drawn arrows make only steps among them\. Door-statechart\/tlc\/regions-all\.mmd has all 7 arrows\.\n5 work, 1 breaks a rule\.\n/, + ); + const lines = proved.chart.split("\n"); + assert.ok(lines.includes(' state "Where you are" as region0 {')); + assert.ok(lines.includes(" --")); + const guarded = lines.findIndex((l) => /: the reply arrives \[when Window open\]$/.test(l)); + assert.match(lines[guarded + 1], /^ {6}%% when r0s\d$/); + assert.doesNotMatch(proved.chart, /^ {6}r1s\d --> r1s\d : you open the window/m); + assert.equal( + proved.every + .split("\n") + .filter((l) => /^ {6}(r1s\d) --> \1 : you open the window \[when Window closed\]$/.test(l)) + .length, + 1, + ); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const boxed = run("NoShownWhileClosed"); + assert.equal(boxed.every, null); + assert.match( + boxed.stdout, + /\nIt has no parallel regions, because "Window closed: reply shown" is "Breaks a rule", and its two region states would show "Works"\.\n/, + ); + assert.doesNotMatch(boxed.chart, /^ +--$/m); + assert.match(boxed.chart, /^ {2}state "Window closed" as g\d \{$/m); + }, +); + +test( + "the mapping's guard words show on an arrow only when every model step behind it has them", + needsTlc, + () => { + const run = runDoor( + `${doorMap}export const guard = (a, before) => + ({ Answer: "window open", Drop: "window closed", Ask: before.where === "Open" ? "window open" : "" })[a] ?? ""; +`, + ); + const proved = run("NoLostReply"); + const lines = proved.chart.split("\n"); + for (const words of [ + /: the reply arrives \[window open\]$/, + /: the app drops the reply \[window closed\]$/, + ]) + assert.match(lines[lines.findIndex((l) => words.test(l)) + 1], /^ {6}%% when r0s\d$/); + assert.ok(lines.some((l) => /^ {6}r1s\d --> r1s\d : you ask$/.test(l))); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + proved.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + assert.match(proved.bug, / : \d\. the app drops the reply \[window closed\]$/m); + + const boxed = run("NoShownWhileClosed"); + assert.match(boxed.chart, / : you ask \[window open\]$/m); + assert.match(boxed.chart, / : you ask$/m); + }, +); + +test( + "the mapping's timer() writes after N s on timer steps, and refuses a value that is not seconds", + needsTlc, + () => { + const timed = runDoor( + `${doorMap}export const timer = (a) => (a === "Drop" ? 30 : undefined);\n`, + )("NoLostReply"); + assert.match(timed.chart, / : after 30 s, the app drops the reply \[when Window closed\]$/m); + assert.match(timed.bug, / : \d\. after 30 s, the app drops the reply$/m); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + timed.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Door.tla"), doorSpec); + writeFileSync(join(dir, "Door.cfg"), "SPECIFICATION Spec\nINVARIANT NoLostReply\n"); + writeFileSync(join(dir, "Door.map.mjs"), `${doorMap}export const timer = () => "soon";\n`); + const r = node("statechart.mjs", ["from-spec", "Door.tla"], dir); + assert.notEqual(r.status, 0); + assert.match( + r.stderr, + /timer\(\) must return a number of seconds above 0, or nothing; got "soon" for \w+/, + ); + }, +); + +const groupMap = `${doorMap}export const group = (v) => + ({ none: "Nothing to show", pending: "Waiting", shown: "Reply shown", lost: "Reply shown" })[v.reply]; +export const groupColors = { "Reply shown": "#d1f2eb", Waiting: null, "Never drawn": "#ffffff" }; +`; + +test( + "group() fills each state with its group's color, and the legend lists only the groups the chart draws", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const lines = run.chart.split("\n"); + assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [ + "---", + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", + "stateDiagram-v2", + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group0 fill:#d1f2eb,stroke:#697976,color:#1b2631", + " classDef group0Wrong fill:#d1f2eb,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " classDef group1 fill:#d6eaf8,stroke:#6b757c,color:#1b2631", + " classDef group2 fill:#fdebd0,stroke:#7f7668,color:#1b2631", + ' state "Legend" as legend {', + ' state "Reply shown" as legendGroup0', + ' state "Waiting" as legendGroup1', + ' state "Nothing to show" as legendGroup2', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendGroup1 group1", + " class legendGroup2 group2", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(' state "reply lost" as r1s1')); + assert.deepEqual( + lines.filter((l) => l.startsWith(" class r")), + [ + " class r0s0 plain", + " class r0s1 plain", + " class r1s0 group2", + " class r1s1 group0Wrong", + " class r1s2 group1", + " class r1s3 group0", + ], + ); + assert.match(run.stdout, /\n5 work, 1 breaks a rule\.\n/); + const again = node( + "compare-charts.mjs", + ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"], + run.out, + ); + assert.equal(again.stdout, "same\n", again.stderr); + + const bug = run.bug.split("\n"); + assert.ok( + bug.includes( + " classDef group1Path fill:#d6eaf8,stroke:#1f3a93,stroke-width:4px,color:#1b2631", + ), + ); + assert.deepEqual( + bug.filter((l) => /^ {2}class s\d/.test(l)), + [ + " class s0 group0Wrong", + " class s1 group1Path", + " class s2 group2Path", + " class s3 group1Path", + ], + ); + + const summary = JSON.parse(readFileSync(join(run.out, "summary.json"), "utf8")); + assert.deepEqual(summary.chartGroups, [ + { name: "Reply shown", color: "#d1f2eb", states: 2 }, + { name: "Waiting", color: "#d6eaf8", states: 1 }, + { name: "Nothing to show", color: "#fdebd0", states: 1 }, + ]); + assert.deepEqual(summary.mixedGroups, []); + }, +); + +test( + "the mapping cannot hide a broken rule, and refuses a status name, a dark or bad color, or a group that is not a name", + needsTlc, + () => { + const run = runDoor(groupMap)("NoLostReply"); + const tlcDir = join(run.out, "tlc"); + const chart = (map, ...extra) => { + writeFileSync(join(tlcDir, "probe.map.mjs"), map); + return node( + "tlc-to-chart.mjs", + [ + "--dot", + "Door.dot", + "--map", + "probe.map.mjs", + "--traces", + "Door-NoLostReply.out", + "--highlight", + "0", + ...extra, + ], + tlcDir, + ); + }; + + const hidden = chart( + `${doorMap}export const group = () => "All good";\nexport const groupColors = { "All good": "#e9f7ef" };\n`, + "--regions", + ); + assert.equal(hidden.status, 0, hidden.stderr); + const lines = hidden.stdout.split("\n"); + assert.ok( + lines.includes( + " classDef group0Wrong fill:#e9f7ef,stroke:#c0392b,stroke-width:4px,color:#7b241c", + ), + ); + const legend = lines.indexOf(' state "Legend" as legend {'); + assert.deepEqual(lines.slice(legend, legend + 6), [ + ' state "Legend" as legend {', + ' state "All good" as legendGroup0', + ' state "Breaks a rule" as legendWrong', + " }", + " class legendGroup0 group0", + " class legendWrong plainWrong", + ]); + assert.ok(lines.includes(" class r1s1 group0Wrong")); + assert.equal(lines.filter((l) => / group0Wrong$/.test(l)).length, 1); + + const coarse = chart( + `const W = { Open: "Window open", Closed: "Window closed" }; +export const state = (v) => W[v.where]; +export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting"); +`, + "--summary", + "coarse.json", + ); + assert.equal(coarse.status, 0, coarse.stderr); + assert.deepEqual( + coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)), + [ + " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631", + " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c", + " class legendWrong plainWrong", + " class s0 plainWrong", + " class s1 plain", + ], + ); + const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8")); + assert.deepEqual(summary.chartGroups, []); + assert.deepEqual(summary.mixedGroups, ["Window closed", "Window open"]); + + for (const [map, error] of [ + [ + `export const group = (v) => (v.reply === "lost" ? "Breaks a rule" : "Fine");`, + /group "Breaks a rule" has the name of a status; TLC decides the status, so name the group for what the reader sees/, + ], + [`export const group = () => "fixed";`, /group "fixed" has the name of a status/], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "#123" };`, + /groupColors\["All good"\] "#123" is too dark for the status borders to show on it/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = { "All good": "red" };`, + /groupColors\["All good"\] "red" is not "#rgb" or "#rrggbb"/, + ], + [ + `export const group = () => "All good";\nexport const groupColors = ["#ffffff"];`, + /groupColors must be an object from a group name to a color such as #d6eaf8/, + ], + [`export const group = () => 1;`, /group\(\) must return a group name or nothing; got 1/], + ]) { + const r = chart(`${doorMap}${map}\n`, "--regions"); + assert.equal(r.status, 1, map); + assert.equal(r.stdout, "", map); + assert.match(r.stderr, error); + } + }, +); + +const order = (name, note, charge) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, charges, note +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ note = 0 +Pay == screen = "Cart" /\\ screen' = "Paying" /\\ note' = ${note} /\\ UNCHANGED charges +Charge == screen = "Paying" /\\ charges < 2 /\\ ${charge} +Next == Pay \\/ Charge +Spec == Init /\\ [][Next]_vars +NoDoubleCharge == charges <= 1 +==== +`; +const chargeAgain = "charges' = charges + 1 /\\ UNCHANGED <>"; +const chargeOnce = `IF charges = 0 THEN charges' = 1 /\\ UNCHANGED <> ELSE screen' = "Paid" /\\ UNCHANGED <>`; +const chargeFirstOnly = "charges = 0 /\\ charges' = 1 /\\ UNCHANGED <>"; + +test( + "the fix changes a bug only at the first step that the reader sees differently", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync(join(dir, "Order.tla"), order("Order", 0, chargeAgain)); + writeFileSync(join(dir, "Order.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n"); + const withFix = (charge, map = payMap) => { + writeFileSync(join(dir, "Order.map.mjs"), map); + writeFileSync(join(dir, "OrderFixed.tla"), order("OrderFixed", 1, charge)); + const r = node( + "statechart.mjs", + ["from-spec", "Order.tla", "--fixed", "OrderFixed.tla"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const chart = readFileSync(join(dir, "Order-statechart", "chart.mmd"), "utf8"); + return { + story: readFileSync(join(dir, "Order-statechart", "stories.md"), "utf8"), + fixEdges: parseMermaid(chart) + .regions.Main.transitions.filter((t) => t.label.includes("with the fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`), + }; + }; + const hidden = withFix(chargeAgain); + assert.deepEqual(hidden.fixEdges, []); + assert.match( + hidden.story, + /^With the fix you see the same screens and steps, so the fix does not change this path\.$/m, + ); + const once = withFix(chargeOnce); + assert.deepEqual(once.fixEdges, [ + "Paying, charged --> Paid, charged : with the fix, the bank charges you", + ]); + assert.match( + once.story, + /^With the fix, step 3 \(the bank charges you\) takes you to: Paid, charged\.$/m, + ); + const unlabeledMap = payMap.replace( + /^export const state = .*$/m, + "export const state = (v) => v.screen;", + ); + const unseen = withFix(chargeOnce, unlabeledMap); + assert.deepEqual(unseen.fixEdges, ["Paying --> Paid : with the fix, the bank charges you"]); + assert.match( + unseen.story, + /^With the fix, after step 1, "the bank charges you" happens and takes you to: Paid\.$/m, + ); + const blocked = withFix(chargeFirstOnly, unlabeledMap); + assert.deepEqual(blocked.fixEdges, []); + assert.match( + blocked.story, + /yet the rule holds, so the state labels do not show what the fix changes/, + ); + }, +); + +const notify = (name, mutedArrive) => `---- MODULE ${name} ---- +EXTENDS Naturals +VARIABLES screen, muted, banner, badge, missed, msgs, syncs +vars == <> +Init == screen = "Inbox" /\\ muted = FALSE /\\ banner = FALSE /\\ badge = FALSE + /\\ missed = FALSE /\\ msgs = 0 /\\ syncs = 0 +OpenThread == screen = "Inbox" /\\ screen' = "Thread" /\\ UNCHANGED <> +OpenSettings == screen \\in {"Inbox", "Thread"} /\\ screen' = "Settings" /\\ UNCHANGED <> +TapBack == screen \\in {"Thread", "Settings"} /\\ screen' = "Inbox" /\\ UNCHANGED <> +Mute == screen = "Settings" /\\ ~muted /\\ muted' = TRUE /\\ UNCHANGED <> +Refresh == syncs < 2 /\\ syncs' = syncs + 1 /\\ UNCHANGED <> +Arrive == msgs < syncs /\\ msgs' = msgs + 1 + /\\ (IF muted THEN ${mutedArrive} ELSE banner' = TRUE /\\ UNCHANGED <>) + /\\ UNCHANGED <> +Next == OpenThread \\/ OpenSettings \\/ TapBack \\/ Mute \\/ Refresh \\/ Arrive +Spec == Init /\\ [][Next]_vars +NeverMissed == ~missed +NoBannerOnThread == ~(banner /\\ screen = "Thread") +==== +`; + +test( + "a step the reader does not see breaks the rule, and the fix shows it on another screen", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Notify.tla"), + notify("Notify", "missed' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "NotifyBadge.tla"), + notify("NotifyBadge", "badge' = TRUE /\\ UNCHANGED <>"), + ); + writeFileSync( + join(dir, "Notify.cfg"), + "SPECIFICATION Spec\nINVARIANT NeverMissed\nINVARIANT NoBannerOnThread\n", + ); + writeFileSync( + join(dir, "Notify.map.mjs"), + `export const readerActions = ["OpenThread", "OpenSettings", "TapBack", "Mute", "Refresh"]; +export const state = (v) => [v.screen, v.muted && "muted", v.banner && "banner", v.badge && "badge"].filter(Boolean).join(", "); +export const step = (a) => ({ OpenThread: "you open the thread", OpenSettings: "you open settings", TapBack: "you tap Back", Mute: "you mute alerts", Refresh: "you pull to refresh", Arrive: "a message arrives" })[a]; +export const cause = () => null; +`, + ); + const r = node( + "statechart.mjs", + ["from-spec", "Notify.tla", "--fixed", "NotifyBadge.tla", "--label", "the badge fix"], + dir, + ); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Notify-statechart", f), "utf8"); + const fixEdges = (f) => + parseMermaid(out(f)) + .regions.Main.transitions.filter((t) => t.label.includes("with the badge fix")) + .map((t) => `${t.from} --> ${t.to} : ${t.label}`); + const { classes } = JSON.parse(out("summary.json")); + assert.deepEqual( + classes.map((c) => [c.invariant, c.end, c.fix]), + [ + ["NoBannerOnThread", "Thread, banner", "unchanged"], + ["NeverMissed", "Settings, muted", "Settings, muted, badge"], + ["NeverMissed", "Inbox, muted", "Inbox, muted, badge"], + ["NeverMissed", "Settings, muted, banner", "Settings, muted, banner, badge"], + ["NeverMissed", "Thread, muted", "Thread, muted, badge"], + ["NoBannerOnThread", "Thread, muted, banner", "unchanged"], + ["NeverMissed", "Inbox, muted, banner", "Inbox, muted, banner, badge"], + ["NeverMissed", "Thread, muted, banner", "Thread, muted, banner, badge"], + ["NoBannerOnThread", "Thread, muted, banner, badge", "new with the badge fix"], + ], + ); + const missed = classes.filter((c) => c.invariant === "NeverMissed"); + + const story = out("stories.md"); + assert.doesNotMatch(story, /do not show what/); + assert.match( + story, + /^With the badge fix, after step 3, "a message arrives" happens and takes you to: Settings, muted, badge\.$/m, + ); + const settings = classes.find((c) => c.end === "Settings, muted"); + assert.deepEqual(fixEdges(`bug-${settings.n}.mmd`), [ + "Settings, muted --> Settings, muted, badge : with the badge fix, a message arrives", + ]); + const drawn = fixEdges("chart.mmd"); + assert.equal(new Set(drawn).size, drawn.length); + for (const c of missed) + assert.ok( + drawn.includes(`${c.end} --> ${c.end}, badge : with the badge fix, a message arrives`), + `bug ${c.n}`, + ); + }, +); + +test( + "each looping step on a bug path keeps its own label, a null cause groups bugs by end state, and the mapping names the key's colors", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Browse.tla"), + `---- MODULE Browse ---- +EXTENDS Naturals +VARIABLES screen, looks, scrolled +vars == <> +Init == screen = "Cart" /\\ looks = 0 /\\ scrolled = FALSE +Look == screen = "Cart" /\\ looks < 3 /\\ looks' = looks + 1 /\\ UNCHANGED <> +Scroll == screen = "Cart" /\\ looks = 1 /\\ ~scrolled /\\ scrolled' = TRUE /\\ UNCHANGED <> +Pay == screen = "Cart" /\\ looks = 3 /\\ scrolled /\\ screen' = "Paying" /\\ UNCHANGED <> +Fail == screen = "Paying" /\\ screen' = "Failed" /\\ UNCHANGED <> +Hang == screen = "Paying" /\\ screen' = "Stuck" /\\ UNCHANGED <> +Next == Look \\/ Scroll \\/ Pay \\/ Fail \\/ Hang +Spec == Init /\\ [][Next]_vars +NeverLost == screen \\notin {"Failed", "Stuck"} +==== +`, + ); + writeFileSync(join(dir, "Browse.cfg"), "SPECIFICATION Spec\nINVARIANT NeverLost\n"); + writeFileSync( + join(dir, "Browse.map.mjs"), + `export const readerActions = ["Look", "Scroll", "Pay"]; +export const state = (v) => v.screen; +export const step = (a) => ({ Look: "you look", Scroll: "you scroll", Pay: "you pay", Fail: "the payment fails", Hang: "the payment hangs" })[a]; +export const cause = () => null; +export const legend = { works: "Works on main", wrong: "Wrong on main" }; +`, + ); + const r = node("statechart.mjs", ["from-spec", "Browse.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const out = (f) => readFileSync(join(dir, "Browse-statechart", f), "utf8"); + assert.deepEqual( + JSON.parse(out("summary.json")).classes.map((c) => [c.end, c.cause]), + [ + ["Failed", null], + ["Stuck", null], + ], + ); + assert.doesNotMatch(out("stories.md"), /^Why:|null/m); + const loops = (chart) => [...chart.matchAll(/^ {2}(s\d+) --> \1 : (.*)$/gm)].map((m) => m[2]); + assert.deepEqual(loops(out("bug-1.mmd")), ["1. you look
2. you scroll
3, 4. you look"]); + assert.deepEqual(loops(out("chart.mmd")), ["you look or you scroll"]); + const chart = out("chart.mmd"); + assert.ok(chart.includes('state "Works on main" as legendWorks')); + assert.ok(chart.includes('state "Wrong on main" as legendWrong')); + assert.doesNotMatch(chart, /legendFixed/); + }, +); + +test("a rule that stays broken reports only the step that broke it", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Buzz.tla"), + `---- MODULE Buzz ---- +EXTENDS Naturals +VARIABLES x, y, buzzed +vars == <> +Init == x = 0 /\\ y = 0 /\\ buzzed = FALSE +IncX == x < 6 /\\ x' = x + 1 /\\ UNCHANGED <> +IncY == y < 6 /\\ y' = y + 1 /\\ UNCHANGED <> +Ring == x = 2 /\\ ~buzzed /\\ buzzed' = TRUE /\\ UNCHANGED <> +Next == IncX \\/ IncY \\/ Ring +Spec == Init /\\ [][Next]_vars +NeverBuzzed == ~buzzed +==== +`, + ); + writeFileSync(join(dir, "Buzz.cfg"), "SPECIFICATION Spec\nINVARIANT NeverBuzzed\n"); + writeFileSync( + join(dir, "Buzz.map.mjs"), + 'export const state = (v) => (v.buzzed ? "Buzzed" : "Quiet");\nexport const step = () => "time passes";\n', + ); + const r = node("statechart.mjs", ["from-spec", "Buzz.tla"], dir); + assert.equal(r.status, 1, r.stderr); + const traces = parseTraces( + readFileSync(join(dir, "Buzz-statechart", "tlc", "Buzz-NeverBuzzed.out"), "utf8"), + ); + assert.equal(traces.length, 7); + for (const t of traces) + assert.equal( + t.steps.findIndex((s) => s.vars.buzzed), + t.steps.length - 1, + ); +}); + +test("from-spec warns when steps carry the name of the whole next-state action", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const count = (next) => `---- MODULE Count ---- +EXTENDS Naturals +VARIABLES x +Init == x = 0 +Inc == x < 3 /\\ x' = x + 1 +${next} +Spec == \\* not [][Inc]_x + Init /\\ [][Next]_x +==== +`; + writeFileSync(join(dir, "Count.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Count.map.mjs"), "export const state = (v) => String(v.x);\n"); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc \\/ (x = 3 /\\ x' = 0)")); + const unnamed = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(unnamed.status, 0, unnamed.stderr); + assert.match( + unnamed.stderr, + /^Count\.tla: 1 step has the name Next, the whole next-state action/, + ); + writeFileSync(join(dir, "Count.tla"), count("Next == Inc")); + const named = node("statechart.mjs", ["from-spec", "Count.tla"], dir); + assert.equal(named.status, 0, named.stderr); + assert.equal(named.stderr, ""); +}); + +const writeLamp = (dir) => { + writeFileSync( + join(dir, "Lamp.tla"), + "---- MODULE Lamp ----\nVARIABLE on\nInit == on = FALSE\nOn == on' = TRUE\nOff == on' = FALSE\nSpec == Init /\\ [][On \\/ Off]_on\n====\n", + ); + writeFileSync(join(dir, "Lamp.cfg"), "SPECIFICATION Spec\n"); + writeFileSync( + join(dir, "Lamp.map.mjs"), + 'export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "you flip the switch";\n', + ); + return join(dir, "Lamp-statechart"); +}; + +test("a rerun without --png removes the chart.png of an earlier run", needsTlc, () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeLamp(dir); + mkdirSync(out); + writeFileSync(join(out, "chart.png"), ""); + const r = node("statechart.mjs", ["from-spec", "Lamp.tla"], dir); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual( + readdirSync(out).filter((f) => f.endsWith(".png")), + [], + ); +}); + +const writeSlowPay = (dir, ticks, paid = "Paid") => { + writeFileSync( + join(dir, "Pay.tla"), + `---- MODULE Pay ---- +EXTENDS Naturals +VARIABLES screen, charges, ticks +vars == <> +Init == screen = "Cart" /\\ charges = 0 /\\ ticks = 0 +TapPay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED <> +Charge == screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED <> +Done == screen = "Paying" /\\ charges > 0 /\\ screen' = "${paid}" /\\ UNCHANGED <> +Tick == ticks < ${ticks} /\\ ticks' = ticks + 1 /\\ UNCHANGED <> +Spec == Init /\\ [][TapPay \\/ Charge \\/ Done \\/ Tick]_vars +ChargedAtMostTwice == charges <= 2 +==== +`, + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT ChargedAtMostTwice\n"); + writeFileSync( + join(dir, "Pay.map.mjs"), + 'export const state = (v) => v.screen;\nexport const step = (a) => ({ TapPay: "you tap Pay", Charge: "the charge goes through", Done: "the receipt shows" })[a] ?? "";\n', + ); + return join(dir, "Pay-statechart"); +}; +const startRun = (dir, { wrap = [], env = {}, args = ["from-spec", "Pay.tla"] } = {}) => { + const [command, ...rest] = [...wrap, process.execPath, join(scripts, "statechart.mjs"), ...args]; + const child = spawn(command, rest, { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "ignore", "pipe"], + detached: true, + }); + let stderr = ""; + child.stderr.on("data", (d) => { + stderr += d; + }); + return { + child, + done: new Promise((done) => child.on("close", (status) => done({ status, stderr }))), + }; +}; +const until = async (ready) => { + while (!ready()) await sleep(20); +}; +const ownTmp = (dir, name) => { + const tmp = join(dir, name); + mkdirSync(tmp); + return tmp; +}; +const tlcStarted = (tmp) => readdirSync(tmp).some((f) => f.startsWith("tlc-")); +const staged = (dir) => readdirSync(dir).filter((f) => f.startsWith(".")); + +test( + "a run whose Node is killed mid-TLC leaves the output folder as it was, and a rerun charts its own spec", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 60000); + const tmp = ownTmp(dir, "tmp"); + const first = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(300); + first.child.kill("SIGTERM"); + await first.done; + assert.doesNotThrow(() => process.kill(-first.child.pid, 0), "TLC outlives its Node"); + assert.equal(existsSync(out), false); + + writeSlowPay(dir, 200, "Receipt"); + const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir); + process.kill(-first.child.pid, "SIGKILL"); + assert.equal(second.status, 0, second.stderr); + for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) { + const text = readFileSync(join(out, file), "utf8"); + assert.match(text, /Receipt/); + assert.doesNotMatch(text, /Paid/); + } + assert.deepEqual(staged(dir), []); + }, +); + +const changedMidRun = async ({ wrap = [], env = {} }) => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp-a"); + const first = startRun(dir, { wrap, env: { ...env, TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + await sleep(500); + writeSlowPay(dir, 20000, "Receipt"); + const second = startRun(dir, { wrap, env: { ...env, TMPDIR: ownTmp(dir, "tmp-b") } }); + const [paid, receipt] = await Promise.all([first.done, second.done]); + assert.equal(receipt.status, 0, receipt.stderr); + assert.equal(paid.status, 2, paid.stderr); + assert.equal( + paid.stderr, + "Pay.tla changed while this run read it, so the run left Pay-statechart as it was. Run the command again.\n", + ); + const chart = readFileSync(join(out, "chart.mmd"), "utf8"); + assert.match(chart, /Receipt/); + assert.doesNotMatch(chart, /Paid/); + assert.deepEqual(staged(dir), []); +}; + +test( + "a run whose spec changes while it reads it leaves the output folder, and the run that read the new spec charts it", + needsTlc, + () => changedMidRun({}), +); + +const unshare = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"]; +const hidePidNamespace = `#define _GNU_SOURCE +#include +#include +#include +#include +ssize_t readlink(const char *path, char *buf, size_t size) { + static ssize_t (*real)(const char *, char *, size_t); + if (!real) real = dlsym(RTLD_NEXT, "readlink"); + if (path && strcmp(path, "/proc/self/ns/pid") == 0) { errno = EIO; return -1; } + return real(path, buf, size); +} +`; +const hidingPreload = (() => { + if (spawnSync(unshare[0], [...unshare.slice(1), "true"]).status !== 0) return null; + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "hide.c"), hidePidNamespace); + const so = join(dir, "hide.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "hide.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "runs in PID namespaces that hide which namespace they are in chart only their own spec", + { + skip: + needsTlc.skip || + (!hidingPreload && "cannot make a PID namespace, or cannot compile a readlink preload"), + }, + () => changedMidRun({ wrap: unshare, env: { LD_PRELOAD: hidingPreload } }), +); + +test( + "a file that is put in the output folder during a run stays, and the run exits 2", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 20000); + const tmp = ownTmp(dir, "tmp"); + const run = startRun(dir, { env: { TMPDIR: tmp } }); + await until(() => tlcStarted(tmp)); + mkdirSync(out); + writeFileSync(join(out, "notes.md"), "mine\n"); + const r = await run.done; + assert.equal(r.status, 2); + assert.equal( + r.stderr, + "Pay-statechart has a file that this command did not write: notes.md. A run puts its files in place of the whole output folder, so pass --out with a new folder.\n", + ); + assert.deepEqual(readdirSync(out), ["notes.md"]); + assert.deepEqual(staged(dir), []); + }, +); + +const failRenames = `#define _GNU_SOURCE +#include +#include +#include +#include +static size_t hits; +int rename(const char *from, const char *to) { + static int (*real)(const char *, const char *); + if (!real) real = dlsym(RTLD_NEXT, "rename"); + const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS"); + base = base ? base + 1 : to; + if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) { + errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO; + return -1; + } + return real(from, to); +} +`; +const failingRenames = (() => { + const dir = mkdtempSync(join(tmpdir(), "statechart-preload-")); + writeFileSync(join(dir, "rename.c"), failRenames); + const so = join(dir, "rename.so"); + const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "rename.c"), "-ldl"]); + return cc.status === 0 ? so : null; +})(); +test( + "a run whose rename fails after it moves the output folder aside puts the folder back, or says where it is", + { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") }, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeLamp(dir); + const args = ["from-spec", "Lamp.tla", "--out", "shared"]; + const shared = join(dir, "shared"); + const first = node("statechart.mjs", args, dir); + assert.equal(first.status, 0, first.stderr); + const chart = readFileSync(join(shared, "chart.mmd"), "utf8"); + const run = (fails) => + node("statechart.mjs", args, dir, { LD_PRELOAD: failingRenames, RENAME_FAILS: fails }); + + const back = run("NI"); + assert.equal(back.status, 2, back.stderr); + assert.match( + back.stderr, + /^cannot replace shared: EIO: .*\. The run left shared as it was\.\n$/, + ); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), []); + + const stuck = run("NII"); + assert.equal(stuck.status, 2, stuck.stderr); + const named = stuck.stderr.match( + /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\.\n$/, + ); + assert.ok(named, stuck.stderr); + const aside = named[1]; + assert.equal(existsSync(shared), false); + assert.equal(readFileSync(join(dir, aside, "chart.mmd"), "utf8"), chart); + assert.deepEqual(staged(dir), [aside]); + }, +); + +test( + "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeSlowPay(dir, 0); + const tmp = ownTmp(dir, "tmp"); + const log = join(dir, "java.log"); + const java = join(dir, "java"); + writeFileSync( + java, + `#!/bin/sh +for a; do case "$a" in -Djava.io.tmpdir=*) t="\${a#-Djava.io.tmpdir=}";; esac; done +"$REAL_JAVA" "$@" > "$LOG.$$"; s=$? +cat "$LOG.$$" +echo "$t: $(grep 'Naturals.tla' "$LOG.$$")" >> "$LOG" +rm "$LOG.$$" +exit $s +`, + { mode: 0o755 }, + ); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { + JAVA: java, + TLA2TOOLS: tools.jar, + REAL_JAVA: tools.java, + LOG: log, + TMPDIR: tmp, + }); + assert.equal(r.status, 0, r.stderr); + const runs = readFileSync(log, "utf8").trim().split("\n"); + assert.ok(runs.length > 0); + const folders = new Set(); + for (const line of runs) { + const [folder, parsed] = line.split(": "); + assert.ok(folder.startsWith(join(tmp, "tlc-")), line); + assert.equal(parsed, `Parsing file ${join(folder, "Naturals.tla")}`); + folders.add(folder); + } + assert.equal(folders.size, runs.length); + }, +); + +test( + "a run that TLC stops on an error keeps TLC's output and writes no output folder", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + writeFileSync( + join(dir, "Pay.tla"), + "---- MODULE Pay ----\nVARIABLE x\nASSUME FALSE\nInit == x = 0\nSpec == Init /\\ [][x' = x]_x\n====\n", + ); + writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n"); + writeFileSync(join(dir, "Pay.map.mjs"), 'export const state = () => "Cart";\n'); + const tmp = ownTmp(dir, "tmp"); + const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { TMPDIR: tmp }); + assert.equal(r.status, 2); + const [, file] = r.stderr.match( + /^(\S+\/tlc\/Pay\.out): TLC stopped on an error that is not a broken rule: Assumption .* is false\.\n$/, + ); + assert.ok(file.startsWith(tmp), file); + assert.match(readFileSync(file, "utf8"), /Assumption .* is false/); + assert.equal(existsSync(join(dir, "Pay-statechart")), false); + }, +); + +test( + "runs of two specs that overlap on one output folder all finish, and the folder always holds one run's files", + needsTlc, + async () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const out = writeSlowPay(dir, 400); + writeLamp(dir); + const pay = ["from-spec", "Pay.tla", "--out", "shared"]; + const lamp = ["from-spec", "Lamp.tla", "--out", "shared"]; + const alone = {}; + for (const args of [pay, lamp]) { + const solo = join(dir, `${args[1]}-alone`); + const r = node("statechart.mjs", [...args.slice(0, 2), "--out", solo], dir); + assert.equal(r.status, 0, r.stderr); + alone[args[1]] = { + top: readdirSync(solo).sort().join(), + tlc: readdirSync(join(solo, "tlc")).sort().join(), + chart: readFileSync(join(solo, "chart.mmd"), "utf8"), + summary: readFileSync(join(solo, "summary.json"), "utf8"), + }; + } + const shared = join(dir, "shared"); + const seen = new Set(); + let watching = true; + const watch = (async () => { + while (watching) { + try { + seen.add(readdirSync(join(shared, "tlc")).sort().join()); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + await sleep(5); + } + })(); + const runs = []; + for (let i = 0; i < 6; i++) { + runs.push(startRun(dir, { args: i % 2 ? lamp : pay }).done); + await sleep(150); + } + const finished = await Promise.all(runs); + watching = false; + await watch; + for (const r of finished) assert.equal(r.status, 0, r.stderr); + const runsOf = new Map(Object.entries(alone).map(([spec, a]) => [a.tlc, spec])); + for (const listing of seen) assert.ok(runsOf.has(listing), `tlc/ had ${listing}`); + const last = alone[runsOf.get(readdirSync(join(shared, "tlc")).sort().join())]; + assert.equal(readdirSync(shared).sort().join(), last.top); + assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), last.chart); + assert.equal(readFileSync(join(shared, "summary.json"), "utf8"), last.summary); + assert.equal(existsSync(out), false); + assert.deepEqual(staged(dir), []); + }, +); + +test( + "the output folder model keeps every rule, and breaks one with a shared TLC folder, without each guard, or when a failed rename deletes the folder moved aside", + needsTlc, + () => { + const dir = mkdtempSync(join(tmpdir(), "statechart-")); + const model = (ext) => readFileSync(join(scripts, "model", `OutputFolder${ext}`), "utf8"); + const r = node( + "statechart.mjs", + ["from-spec", join(scripts, "model", "OutputFolder.tla"), "--out", join(dir, "out")], + dir, + ); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /\nNo rule is broken\.\n/); + for (const [i, [rule, guard, without, story]] of [ + [ + "ChartsOneSpec", + "/\\ read' = Set(read, r, read[r] \\cup {spec})", + "/\\ read' = [q \\in Runs |-> read[q] \\cup {spec}]", + /You start run 2 in a second shell\..*\n.*Run 2 moves its folder into place\. Now: Two runs going: chart of both specs/, + ], + [ + "ChartsOneSpec", + '/\\ pc[r] = "publish" /\\ spec = snap[r] /\\ ~out.notes', + '/\\ pc[r] = "publish" /\\ ~out.notes', + /You edit the spec\..*\n.*Run 1 moves its folder into place\. Now: One run going: chart of both specs/, + ], + [ + "KeepsYourFiles", + '/\\ pc[r] = "aside" /\\ ~aside[r].notes\n', + '/\\ pc[r] = "aside"\n', + /Run 1 deletes the old folder and finishes\. Now: Done: chart of the spec\n/, + ], + [ + "NeverLosesChart", + " /\\ \\/ /\\ aside[r].exists /\\ ~out.exists\n /\\ out' = aside[r]\n /\\ aside' = Set(aside, r, NoFolder)\n \\/ UNCHANGED <>\n", + " /\\ out' = out\n /\\ aside' = Set(aside, r, IF aside[r].notes THEN aside[r] ELSE NoFolder)\n", + /Run 2 moves the old folder aside\. Now: .*, the old chart moved aside\n.*Run 2 cannot rename a folder and stops\. Now: One run going: no folder\n/, + ], + [ + "NeverLosesChart", + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists /\\ ~out.notes', + '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists', + /You save notes in the folder\..*\n.*Run 2 finds another run's folder in place and deletes the old folder\. Now: Two runs going: your notes\n/, + ], + ].entries()) { + const mutant = join(dir, `${i}-${rule}`); + mkdirSync(mutant); + assert.ok(model(".tla").includes(guard), guard); + writeFileSync(join(mutant, "OutputFolder.tla"), model(".tla").replace(guard, without)); + for (const ext of [".cfg", ".map.mjs"]) + writeFileSync(join(mutant, `OutputFolder${ext}`), model(ext)); + const m = node( + "statechart.mjs", + ["from-spec", "OutputFolder.tla", "--expect-broken", rule], + mutant, + ); + assert.equal(m.status, 0, m.stdout + m.stderr); + assert.match(m.stdout, story); + } + }, +); diff --git a/skills/statechart/scripts/test/sync.mmd b/skills/statechart/scripts/test/sync.mmd new file mode 100644 index 0000000..c780c2e --- /dev/null +++ b/skills/statechart/scripts/test/sync.mmd @@ -0,0 +1,19 @@ +stateDiagram-v2 + state "Notes app" as App { + state "Connection" as Connection { + [*] --> Online + Online --> Offline : the network drops + Offline --> Online : the network returns + } + -- + state "Sync badge" as Sync { + state "Synced" as Synced + state "Waiting to sync" as Pending + [*] --> Synced + Synced --> Pending : you edit a note + Synced --> Pending : the network drops + Pending --> Synced : the server confirms + } + } + [*] --> App + %% never NeverSyncedWhileOffline: Offline & Synced -- You never see "Synced" while you are offline. diff --git a/skills/statechart/scripts/test/upload.mmd b/skills/statechart/scripts/test/upload.mmd new file mode 100644 index 0000000..dcfafa7 --- /dev/null +++ b/skills/statechart/scripts/test/upload.mmd @@ -0,0 +1,16 @@ +stateDiagram-v2 + state "Nothing chosen" as Idle + state "Uploading" as Uploading { + state "Sending the file" as Sending + state "Checking the file" as Checking + [*] --> Sending + Sending --> Checking : the last byte arrives + } + state "Uploaded" as Done + state "Upload failed" as Failed + [*] --> Idle + Idle --> Uploading : you choose a file + Checking --> Done : the check passes + Uploading --> Failed : the network drops + Failed --> Uploading : you retry + Uploading --> Idle : you cancel diff --git a/skills/statechart/scripts/tlc-to-chart.mjs b/skills/statechart/scripts/tlc-to-chart.mjs new file mode 100644 index 0000000..ccb68fd --- /dev/null +++ b/skills/statechart/scripts/tlc-to-chart.mjs @@ -0,0 +1,1347 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { + FULL_LABELS, + GROUP_PALETTE, + LEGEND_ID, + PLAIN_FILL, + checkTlcOutput, + compareCharts, + groupClassDef, + groupColorProblem, + isRecord, + joinLabels, + parseDot, + parseMermaid, + parseTraces, + productOf, + quote, + ruleSentences, + stateKey, + wrap, + STATUS_CLASSES, +} from "./lib.mjs"; + +const USAGE = "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"; +const HELP = `${USAGE} + +Collapses a TLC state graph onto the reader's states and writes a Mermaid stateDiagram-v2. +statechart.mjs runs it once for the overview and once for each bug chart. + + --dot FILE The state graph from TLC -dump dot,actionlabels FILE, for the current code. + Required. + --map FILE The mapping module. Required. It exports state(vars, facts), and + optionally detail(vars, facts), step(action, before, after), + guard(action, before, after), timer(action, before, after), + cause(vars, facts, invariant), group(vars, facts), groupColors, + regionNames, readerActions, and legend. guard is when the step + can happen, in plain words. An edge reads "step [words]" when every + model step behind it has words. timer is how many seconds the code waits + before a timer step, and the step reads "after N s, step". + cause is the hidden reason a bad state is bad: counterexamples with the + same invariant and cause are one bug. When cause returns nothing, the + end state stands in for it. readerActions are the actions the + reader takes. group is the group of a state, below. legend, such as + { works: "Works on main" }, gives the legend's words for the works, + wrong, and fixed statuses. + facts.enabled is the set of actions the state enables. facts.settled is + true when only reader actions are enabled, so nothing else happens until + the reader acts. + --traces FILE TLC output that holds counterexamples. Repeat it. TLC -continue gives one + per bad state. It refuses a file from a TLC run that stopped early or hit + an error other than a broken rule. + --fixed-dot FILE The state graph of the model with the open fix. Each bug's steps replay + on it. + --fixed-traces FILE TLC output for the same invariants on the fixed model. Repeat it. It marks + states fixed or still wrong, and adds the bugs that only the fixed model + has after the current model's bugs. + --against-dot FILE The state graph of the spec that the current code must follow. + Each bug's steps replay on it. Do not use it with --fixed-dot. + --against-traces FILE TLC output for the spec. Repeat it. Its bugs are drawn wrong, and the + bugs that only the spec has number after the code's bugs. + --label TEXT The name of the second model in the chart and the stories. Default: + "the fix" with --fixed-dot, "the spec" with --against-dot. + --spec FILE A TLA+ file whose \\* comment lines above a rule are the rule's sentence in + the stories. Repeat it. The first file that has a sentence wins. + --view NAME customer (state labels, the default), developer (detail labels), or + layered (detail inside state). + --cover Keep only the shortest path to each settled reader state, plus the + counterexample paths. + --highlight N Draw bug N as a numbered path. Default 1. 0 draws none. + --path-only Draw only the highlighted path. + --full-labels Write each label on an edge in full, joined by " or ", and mark the chart + "${FULL_LABELS}". Default: write the leading words that the labels + share once, as in "you tap Pay or Back". + --flat Draw every state on its own, with no boxes. + --regions When every state reads "context: progress", draw two parallel regions, + context and progress, when they are smaller and proved. They draw the + steps of the shortest way to each region state, of each bug path, and + the nearest step with each label. + Otherwise draw the chart the other flags ask for, and print why. + Customer view, --highlight 0, no --flat or --path-only. + --proof FILE With --regions, write the flat chart that the proof compares with. + --regions-all FILE With --regions, also write the regions with every step. + --title TEXT The chart title. + --out FILE The Mermaid chart. Default: stdout. + --story FILE The reader stories, one per bug. + --summary FILE The counts for the chart and the bugs, as JSON. + -h, --help Print this help, and exit 0. + +Each bug replays on the second model by its screens and step words, which are what the reader +sees. The replay takes the second model's steps that keep the screen and that the reader does not +take. A bug step that the reader does not see must keep the screen on the second model too, unless +the second model cannot take it. The replay stops at the first step that the second model shows +the reader differently. There the chart draws "with LABEL, STEP" (or "in LABEL, STEP" for +--against-dot) to where the second model goes, or a loop "... STEP cannot happen" when it cannot +take a step that the reader sees. + +The second model may have other variables and actions than the current model. Variables and +record fields that one start state has and the other does not are left out when the two models +are compared. The second model's states take the current model's start values for the variables +it does not have, so one mapping draws both. A step of an action that the second model does not +have is skipped when it changes no shared variable, and matches any step to the same shared state +when it does. + +In the customer view, states whose labels read "context: progress" with the same context sit in +one box named context, and each shows only its progress. A context with one state stays a plain +state. An arrow from a box's border stands for the same step from every state in the box: it is +drawn only when every state in the box takes that step, with the same labels, to the same state. +The layered view puts each detail state in a box named by its state label. + +With --regions, each "context: progress" label is one state in the context region and one in the +progress region. A step that happens beside only some states of the other region gets a guard: +"[when A or B]" or "[unless A or B]", whichever is shorter, and a "%% when [not] ids" line under it. +The mapping's guard words, when the step has them, replace "when A or B" in the label. +The proof combines the regions with every step as compare-charts.mjs does, and needs exactly the +flat chart's states, start states, and labeled steps. Each state's color must be the worse color +of its two region states. Then the drawn arrows, combined, must make only steps of the flat chart: +a step that they make wrongly gets back every arrow that the full regions take there. +compare-charts.mjs on the --regions-all and --proof files repeats the first proof. +mapping.regionNames = [context, progress] names the two regions. + +Outside regions, two states have at most one arrow. Its lines are the numbered steps, then the +current model's steps, then the second model's steps, with a blank line between each group. + +Without group(), the legend shows only the statuses the chart uses. A chart with a second model +has the line "%% second model (fix|against): LABEL" right after stateDiagram-v2. Each chart +starts with front matter that keeps Mermaid from shrinking it to the page or rewrapping its labels. + +Groups: group(vars, facts) returns the name of the group that a model state is in, such as +"Toast shown", or nothing. A chart state is in a group when all its model states are in it. A +region state is in a group when every state that it is part of is in it. Each group fills its +states with one color, and the legend lists only the groups that the chart draws: first the +groups in groupColors, in that order, then the others from A to Z. groupColors, such as +{ "Toast shown": "#d6eaf8" }, gives a group a light "#rgb" or "#rrggbb" color. A group with no +color, or null, takes the next free palette color: light blue, orange, purple, yellow, teal, tan, +then pink. A state in no group, or in more than one, is gray. summary.json counts the drawn +states of each group in chartGroups, and lists the chart states with more than one in mixedGroups. +TLC decides the status, and a group cannot change it. On top of the group color, a state that +breaks a rule has a thick red border, and a fixed state or a state only in the second model a +thick green border. The legend then shows that border. A thick blue border marks the bug path on +states that work. On other states of the path, the numbered arrows show it. A group cannot have +the name of a status, such as "Breaks a rule". + +Writes only the files that --out, --story, and --summary name. + +Exit codes + 0 It wrote the chart. + 1 It cannot use an input: a mapping without state(), TLC output that it refuses, or a + --highlight with no such bug. The error says why. + 2 A bad argument.`; +const more = "Run node tlc-to-chart.mjs --help for the flags."; + +const args = { + traces: [], + fixedTraces: [], + againstTraces: [], + specs: [], + view: "customer", + highlight: 1, +}; +for (let i = 2; i < process.argv.length; i++) { + const a = process.argv[i]; + const next = () => process.argv[++i]; + if (a === "-h" || a === "--help") { + console.log(HELP); + process.exit(0); + } else if (a === "--dot") args.dot = next(); + else if (a === "--map") args.map = next(); + else if (a === "--traces") args.traces.push(next()); + else if (a === "--fixed-dot") args.fixedDot = next(); + else if (a === "--fixed-traces") args.fixedTraces.push(next()); + else if (a === "--against-dot") args.againstDot = next(); + else if (a === "--against-traces") args.againstTraces.push(next()); + else if (a === "--label") args.label = next(); + else if (a === "--spec") args.specs.push(next()); + else if (a === "--view") args.view = next(); + else if (a === "--highlight") args.highlight = Number(next()); + else if (a === "--path-only") args.pathOnly = true; + else if (a === "--full-labels") args.fullLabels = true; + else if (a === "--flat") args.flat = true; + else if (a === "--regions") args.regions = true; + else if (a === "--proof") args.proof = next(); + else if (a === "--regions-all") args.regionsAll = next(); + else if (a === "--cover") args.cover = true; + else if (a === "--title") args.title = next(); + else if (a === "--out") args.out = next(); + else if (a === "--story") args.story = next(); + else if (a === "--summary") args.summary = next(); + else { + console.error(`unknown argument ${a}. ${more}`); + process.exit(2); + } +} +const fail = (message) => { + console.error(`${message} ${more}`); + process.exit(2); +}; +if (!args.dot || !args.map) fail(USAGE); +if (!["customer", "developer", "layered"].includes(args.view)) + fail(`--view must be customer, developer, or layered, not ${args.view}.`); +if (args.fixedDot && args.againstDot) fail("use --fixed-dot or --against-dot, not both."); +if (args.fixedTraces.length && !args.fixedDot) fail("--fixed-traces needs --fixed-dot."); +if (args.againstTraces.length && !args.againstDot) fail("--against-traces needs --against-dot."); +if (args.label !== undefined && !args.fixedDot && !args.againstDot) + fail("--label names the second model; it needs --fixed-dot or --against-dot."); +if (args.proof && !args.regions) fail("--proof writes the regions proof; it needs --regions."); +if (args.regionsAll && !args.regions) + fail("--regions-all writes every regions arrow; it needs --regions."); +if ( + args.regions && + (args.view !== "customer" || args.flat || args.pathOnly || args.highlight !== 0) +) + fail( + "--regions draws the whole customer chart: use --highlight 0, without --flat or --path-only.", + ); + +const role = args.fixedDot ? "fix" : args.againstDot ? "against" : null; +const L = args.label ?? (role === "fix" ? "the fix" : "the spec"); +const within = role === "fix" ? "with" : "in"; +const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); + +const mapping = await import(pathToFileURL(path.resolve(args.map)).href); +if (typeof mapping.state !== "function") + throw new Error("the mapping module must export state(vars)"); +if (args.view !== "customer" && typeof mapping.detail !== "function") + throw new Error(`--view ${args.view} needs detail(vars) in the mapping module`); +const SEP = "\u0000"; +const edgeId = (from, to) => `${from}${SEP}${SEP}${to}`; + +const rules = new Map(); +for (const f of args.specs) + for (const [name, sentence] of ruleSentences(fs.readFileSync(f, "utf8"))) + if (!rules.has(name)) rules.set(name, sentence); + +const readerActions = new Set(mapping.readerActions ?? []); +const factsFrom = (enabled) => ({ + enabled, + settled: [...enabled].every((a) => readerActions.has(a)), +}); +function keyOf(vars, facts) { + if (args.view === "developer") return String(mapping.detail(vars, facts)); + const outer = mapping.state(vars, facts); + if (typeof outer !== "string") + throw new Error(`state() must return a string; got ${JSON.stringify(outer)}`); + return args.view === "layered" ? `${outer}${SEP}${mapping.detail(vars, facts)}` : outer; +} +function stepLabel(action, before, after, view = args.view) { + const said = typeof mapping.step === "function" ? mapping.step(action, before, after) : action; + const named = view === "customer" || said === action ? said : `${said} [${action}]`; + const seconds = typeof mapping.timer === "function" ? mapping.timer(action, before, after) : null; + if (seconds === undefined || seconds === null || seconds === "" || !named) return named; + if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) + throw new Error( + `timer() must return a number of seconds above 0, or nothing; got ${JSON.stringify(seconds)} for ${action}`, + ); + return `after ${seconds} s, ${named}`; +} +function stepGuard(action, before, after) { + if (typeof mapping.guard !== "function") return ""; + return String(mapping.guard(action, before, after) ?? "") + .replace(/[[\]]/g, "") + .trim(); +} +function guarded(guards, label) { + const words = guards?.get(label); + return words && !words.has("") ? `${label} [${[...words].sort().join(" or ")}]` : label; +} +const addGuard = (guards, label, words) => { + if (!guards.has(label)) guards.set(label, new Set()); + guards.get(label).add(words); +}; + +function lacking(a, b, prefix = []) { + return Object.keys(a).flatMap((k) => + !(k in b) + ? [[...prefix, k]] + : isRecord(a[k]) && isRecord(b[k]) + ? lacking(a[k], b[k], [...prefix, k]) + : [], + ); +} +const holder = (o, p) => p.slice(0, -1).reduce((x, k) => (isRecord(x?.[k]) ? x[k] : null), o); +function omit(vars, paths) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const o = holder(out, p); + if (o) delete o[p.at(-1)]; + } + return out; +} +function fill(vars, paths, from) { + if (!paths.length) return vars; + const out = structuredClone(vars); + for (const p of paths) { + const [o, f] = [holder(out, p), holder(from, p)]; + if (o && f && p.at(-1) in f) o[p.at(-1)] = structuredClone(f[p.at(-1)]); + } + return out; +} + +function modelOf(graph, lookup = (vars) => vars) { + const byId = new Map([...graph.nodes.keys()].map((id) => [id, new Set()])); + for (const e of [...graph.edges, ...graph.loops]) byId.get(e.from).add(e.action); + const index = new Map( + [...graph.nodes.values()].map((n) => [stateKey(n.raw ?? n.vars), byId.get(n.id)]), + ); + const facts = (vars) => factsFrom(index.get(stateKey(lookup(vars))) ?? new Set()); + return { + graph, + facts, + key: (vars) => keyOf(vars, facts(vars)), + customer: (vars) => String(mapping.state(vars, facts(vars))), + }; +} + +function quotient(m) { + const { graph } = m; + const keys = new Map(); + for (const n of graph.nodes.values()) keys.set(n.id, m.key(n.vars)); + const nodes = new Set(keys.values()); + const initial = new Set( + [...graph.nodes.values()].filter((n) => n.init).map((n) => keys.get(n.id)), + ); + const edges = new Map(); + for (const e of graph.edges) { + const from = keys.get(e.from); + const to = keys.get(e.to); + if (from === to) continue; + const id = edgeId(from, to); + if (!edges.has(id)) edges.set(id, { from, to, counts: new Map(), guards: new Map() }); + const [before, after] = [graph.nodes.get(e.from).vars, graph.nodes.get(e.to).vars]; + const label = stepLabel(e.action, before, after); + if (!label) continue; + edges.get(id).counts.set(label, (edges.get(id).counts.get(label) ?? 0) + 1); + addGuard(edges.get(id).guards, label, stepGuard(e.action, before, after)); + } + for (const e of edges.values()) + e.labels = new Set([...e.counts].sort((a, b) => b[1] - a[1]).map(([l]) => l)); + const settled = new Set( + [...graph.nodes.values()].filter((n) => m.facts(n.vars).settled).map((n) => keys.get(n.id)), + ); + return { nodes, initial, edges, keys, settled }; +} + +const graph = parseDot(fs.readFileSync(args.dot, "utf8")); +const main = modelOf(graph); +const now = quotient(main); +const mainInit = [...graph.nodes.values()].find((n) => n.init).vars; + +const secondDot = args.fixedDot ?? args.againstDot; +const secondGraph = secondDot ? parseDot(fs.readFileSync(secondDot, "utf8")) : null; +const secondInit = secondGraph && [...secondGraph.nodes.values()].find((n) => n.init).vars; +const lacks = secondGraph ? lacking(mainInit, secondInit) : []; +const extra = secondGraph ? lacking(secondInit, mainInit) : []; +const secondFilled = secondGraph && { + ...secondGraph, + nodes: new Map( + [...secondGraph.nodes].map(([id, n]) => [ + id, + { ...n, raw: n.vars, vars: fill(n.vars, lacks, mainInit) }, + ]), + ), +}; +const second = secondFilled && modelOf(secondFilled, (vars) => omit(vars, lacks)); +const secondQ = second && quotient(second); + +function readerPath(trace, key, labelOf = stepLabel) { + const out = [{ key: key(trace.steps[0].vars), label: null, modelStep: 0 }]; + for (let i = 1; i < trace.steps.length; i++) { + const { action } = trace.steps[i]; + const k = key(trace.steps[i].vars); + if (k === out[out.length - 1].key && !readerActions.has(action)) continue; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const label = labelOf(action, before, after) || action; + const guard = stepGuard(action, before, after); + out.push({ key: k, label, guard, action, modelStep: i }); + } + return out; +} + +const readerLabel = (action, before, after) => stepLabel(action, before, after, "customer"); +function replaySecond(trace) { + if (!second) return null; + const shared = (vars) => stateKey(omit(vars, lacks)); + const sharedOf = (n) => stateKey(omit(n.raw, extra)); + const out = new Map(); + for (const e of [...second.graph.edges, ...second.graph.loops]) { + if (!out.has(e.from)) out.set(e.from, []); + out.get(e.from).push(e); + } + const actions = new Set([...second.graph.edges, ...second.graph.loops].map((e) => e.action)); + const nodeOf = (id) => second.graph.nodes.get(id); + const screen = new Map( + [...second.graph.nodes.values()].map((n) => [n.id, second.customer(n.vars)]), + ); + const words = (e) => readerLabel(e.action, nodeOf(e.from).vars, nodeOf(e.to).vars) || e.action; + const settle = (ids) => { + const all = new Set(ids); + for (const id of all) + for (const e of out.get(id) ?? []) + if (!readerActions.has(e.action) && screen.get(e.to) === screen.get(id)) all.add(e.to); + return all; + }; + const seen = readerPath(trace, main.customer, readerLabel); + const shownAt = new Map(seen.map((p, j) => [p.modelStep, j])); + let here = settle( + [...second.graph.nodes.values()] + .filter((n) => n.init && screen.get(n.id) === seen[0].key) + .map((n) => n.id), + ); + if (here.size === 0) return { reason: "starts on another screen" }; + const passed = new Set(here); + let j = 0; + for (let i = 1; i < trace.steps.length; i++) { + const { action } = trace.steps[i]; + const hidden = !shownAt.has(i); + if (!hidden) j = shownAt.get(i); + const want = seen[j]; + const [before, after] = [trace.steps[i - 1].vars, trace.steps[i].vars]; + const known = actions.has(action); + if (!known && (hidden || shared(before) === shared(after))) continue; + const tries = [...here].flatMap((id) => + (out.get(id) ?? []).filter((e) => + known ? e.action === action : sharedOf(nodeOf(e.to)) === shared(after), + ), + ); + if (hidden && tries.length === 0) continue; + const same = known + ? tries.filter((e) => screen.get(e.to) === want.key && (hidden || words(e) === want.label)) + : tries; + if (same.length) { + here = settle(same.map((e) => e.to)); + for (const id of here) passed.add(id); + continue; + } + const outcomes = new Map(); + for (const e of tries) { + const id = `${screen.get(e.to)}${SEP}${words(e)}`; + outcomes.set(id, [...(outcomes.get(id) ?? []), e]); + } + const taken = [...outcomes.values()].sort((a, b) => b.length - a.length)[0]?.[0]; + const from = main.key(before); + const said = hidden ? readerLabel(action, before, after) || action : want.label; + const at = { step: j, said, from, hidden }; + if (!taken) + return { + ...at, + key: from, + label: stepLabel(action, before, after) || action, + disabled: true, + }; + const [b, a] = [nodeOf(taken.from).vars, nodeOf(taken.to).vars]; + return { ...at, key: second.key(a), label: stepLabel(taken.action, b, a) || taken.action }; + } + const bad = new Set( + rawSecondTraces + .filter((t) => t.invariant === trace.invariant) + .map((t) => stateKey(t.steps[t.steps.length - 1].vars)), + ); + const ruleHolds = + secondTraceFiles.length > 0 && ![...passed].some((id) => bad.has(stateKey(nodeOf(id).vars))); + return { sameEnd: true, ruleHolds }; +} + +function readTraces(files, paths = []) { + return files.flatMap((f) => { + const text = fs.readFileSync(f, "utf8"); + checkTlcOutput(text, f); + return parseTraces(text).map((t) => ({ + ...t, + steps: t.steps.map((s) => ({ ...s, vars: fill(s.vars, paths, mainInit) })), + })); + }); +} +// With -continue, TLC reports every bad state, including states after the first bad one. +function firstBad(raw) { + const badStates = new Map(); + for (const t of raw) { + if (!badStates.has(t.invariant)) badStates.set(t.invariant, new Set()); + badStates.get(t.invariant).add(stateKey(t.steps[t.steps.length - 1].vars)); + } + const cuts = []; + const seen = new Set(); + for (const t of raw) { + const first = t.steps.findIndex((s) => badStates.get(t.invariant).has(stateKey(s.vars))); + const cut = { invariant: t.invariant, steps: t.steps.slice(0, first + 1) }; + const id = `${t.invariant}${SEP}${stateKey(cut.steps[cut.steps.length - 1].vars)}`; + if (seen.has(id)) continue; + seen.add(id); + cuts.push(cut); + } + return cuts; +} +const rawTraces = readTraces(args.traces); +const secondTraceFiles = role === "fix" ? args.fixedTraces : args.againstTraces; +const rawSecondTraces = readTraces(secondTraceFiles, lacks); +const traces = firstBad(rawTraces); +const secondTraces = firstBad(rawSecondTraces); +function group(cuts, m) { + const byId = new Map(); + for (const trace of cuts) { + const rp = readerPath(trace, m.customer); + const last = trace.steps[trace.steps.length - 1].vars; + const said = + typeof mapping.cause === "function" + ? mapping.cause(last, m.facts(last), trace.invariant) + : null; + const cause = said === undefined || said === null ? null : String(said); + const id = `${trace.invariant}${SEP}${cause ?? rp[rp.length - 1].key}`; + const prev = byId.get(id); + if ( + !prev || + rp.length < prev.steps || + (rp.length === prev.steps && trace.steps.length < prev.trace.steps.length) + ) { + byId.set(id, { + id, + cause, + steps: rp.length, + trace, + invariant: trace.invariant, + count: (prev?.count ?? 0) + 1, + }); + } else prev.count++; + } + return byId; +} +const byOrder = (a, b) => + a.steps - b.steps || + a.trace.steps.length - b.trace.steps.length || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +const inSecond = (l) => `${within} ${L}, ${l}`; +const modelEdges = new Map(now.edges); +if (secondQ) + for (const [id, e] of secondQ.edges) + if (!modelEdges.has(id)) + modelEdges.set(id, { + ...e, + labels: new Set([...e.labels].map(inSecond)), + guards: new Map([...e.guards].map(([l, words]) => [inSecond(l), words])), + secondOnly: true, + }); +function divergence(s) { + if (s?.key === undefined) return s; + const plain = edgeId(s.from, s.key); + const text = inSecond(`${s.label}${s.disabled ? " cannot happen" : ""}`); + const drawn = !s.disabled && [s.label, text].some((l) => modelEdges.get(plain)?.labels.has(l)); + return { ...s, text, edge: drawn ? plain : `${plain}${SEP}second` }; +} +const withPath = (c, m) => { + const path = readerPath(c.trace, m.key); + return { ...c, path, end: path[path.length - 1].key }; +}; +const classesById = group(traces, main); +const mainClasses = [...classesById.values()] + .sort(byOrder) + .map((c) => ({ ...withPath(c, main), second: divergence(replaySecond(c.trace)) })); +const introduced = second + ? [...group(secondTraces, second).values()] + .filter((c) => !classesById.has(c.id)) + .sort(byOrder) + .map((c) => ({ ...withPath(c, second), second: null, introduced: true })) + : []; +const classes = [...mainClasses, ...introduced]; + +const diverges = (c) => c.second?.key !== undefined; +function badKeys(m, keys, raw) { + const g = m.graph; + const bad = new Set(raw.map((t) => stateKey(t.steps[t.steps.length - 1].vars))); + const out = new Set(raw.map((t) => m.key(t.steps[t.steps.length - 1].vars))); + const ok = (id) => !bad.has(stateKey(g.nodes.get(id).vars)); + const next = new Map(); + for (const e of g.edges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const clean = new Set([...g.nodes.values()].filter((n) => n.init && ok(n.id)).map((n) => n.id)); + const queue = [...clean]; + while (queue.length) { + for (const to of next.get(queue.shift()) ?? []) { + if (clean.has(to) || !ok(to)) continue; + clean.add(to); + queue.push(to); + } + } + const reached = new Set([...clean].map((id) => keys.get(id))); + for (const k of keys.values()) if (!reached.has(k)) out.add(k); + return out; +} +const badNow = badKeys(main, now.keys, rawTraces); +const badSecond = + second && secondTraceFiles.length > 0 + ? badKeys(second, secondQ.keys, rawSecondTraces) + : role === "fix" + ? badNow + : new Set(); +const allNodes = new Set([...now.nodes, ...(secondQ?.nodes ?? [])]); +const initial = new Set([...now.initial, ...(secondQ?.initial ?? [])]); +const onlySecond = (k) => second && !now.nodes.has(k); +const status = (k) => { + if (role === "against") + return badNow.has(k) || badSecond.has(k) ? "wrong" : onlySecond(k) ? "fixed" : "works"; + if (role === "fix") + return badSecond.has(k) ? "wrong" : badNow.has(k) || onlySecond(k) ? "fixed" : "works"; + return badNow.has(k) ? "wrong" : "works"; +}; + +const chosen = args.highlight > 0 ? classes[args.highlight - 1] : null; +if (args.highlight > 0 && classes.length > 0 && !chosen) + throw new Error(`--highlight ${args.highlight}: only ${classes.length} classes`); +const onPath = new Set(chosen ? chosen.path.map((p) => p.key) : []); +if (chosen && diverges(chosen)) onPath.add(chosen.second.key); + +const edgesAlong = (c) => + c.path.slice(1).map((p, i) => ({ + id: edgeId(c.path[i].key, p.key), + from: c.path[i].key, + to: p.key, + label: p.label, + guard: p.guard, + })); +function chartEdges() { + const edges = new Map(modelEdges); + for (const { second: s } of classes.filter(diverges)) { + if (modelEdges.has(s.edge)) continue; + if (!edges.has(s.edge)) + edges.set(s.edge, { from: s.from, to: s.key, labels: new Set(), secondOnly: true }); + edges.get(s.edge).labels.add(s.text); + } + const pathNumbers = new Map(); + (chosen ? edgesAlong(chosen) : []).forEach((step, i) => { + if (!pathNumbers.has(step.id)) pathNumbers.set(step.id, []); + const shown = step.guard ? `${step.label} [${step.guard}]` : step.label; + pathNumbers.get(step.id).push({ n: i + 1, label: step.label, shown }); + if (!edges.has(step.id)) + edges.set(step.id, { from: step.from, to: step.to, labels: new Set([step.label]) }); + }); + if (!args.cover) return { edges, pathNumbers, coverEdges: null }; + + const coverEdges = new Set(); + const bfs = (q, targets) => { + const parent = new Map([...q.initial].map((k) => [k, null])); + const queue = [...q.initial]; + const outEdges = new Map(); + for (const [id, e] of q.edges) { + if (!outEdges.has(e.from)) outEdges.set(e.from, []); + outEdges.get(e.from).push([id, e]); + } + while (queue.length) { + const k = queue.shift(); + for (const [id, e] of outEdges.get(k) ?? []) { + if (parent.has(e.to)) continue; + parent.set(e.to, id); + queue.push(e.to); + } + } + for (const t of targets) { + for (let id = parent.get(t); id; id = parent.get(q.edges.get(id).from)) coverEdges.add(id); + } + }; + bfs(now, now.settled); + if (secondQ) + bfs( + secondQ, + [...secondQ.settled].filter((k) => !now.nodes.has(k)), + ); + const pathLabels = new Map(); + for (const c of classes) { + for (const step of edgesAlong(c)) { + coverEdges.add(step.id); + pathLabels.set(step.id, (pathLabels.get(step.id) ?? new Set()).add(step.label)); + } + if (diverges(c)) coverEdges.add(c.second.edge); + } + for (const id of coverEdges) { + if (edges.has(id)) continue; + const [from, to] = id.split(`${SEP}${SEP}`); + edges.set(id, { from, to, labels: pathLabels.get(id) }); + } + return { edges, pathNumbers, coverEdges }; +} +const { edges, pathNumbers, coverEdges } = chartEdges(); + +const LEGEND = { + works: ["legendWorks", mapping.legend?.works ?? (role === "fix" ? "Works today" : "Works")], + wrong: [ + "legendWrong", + mapping.legend?.wrong ?? (role === "fix" ? `Still wrong with ${L}` : "Breaks a rule"), + ], + fixed: [ + "legendFixed", + mapping.legend?.fixed ?? (role === "fix" ? `Fixed or new with ${L}` : `Only in ${L}`), + ], +}; + +const grouping = typeof mapping.group === "function"; +const groupsOf = new Map(); +const groupOf = (k) => (groupsOf.get(k)?.size === 1 ? [...groupsOf.get(k)][0] : ""); +const groupNames = []; +const fills = new Map(); +if (grouping) { + const add = (k, vars, m) => { + const g = mapping.group(vars, m.facts(vars)); + if (g !== undefined && g !== null && typeof g !== "string") + throw new Error(`group() must return a group name or nothing; got ${JSON.stringify(g)}`); + if (!groupsOf.has(k)) groupsOf.set(k, new Set()); + groupsOf.get(k).add((g ?? "").replace(/\s+/g, " ").trim()); + }; + for (const n of graph.nodes.values()) add(now.keys.get(n.id), n.vars, main); + if (second) + for (const n of second.graph.nodes.values()) add(secondQ.keys.get(n.id), n.vars, second); + + const given = mapping.groupColors ?? {}; + if (!isRecord(given)) + throw new Error("groupColors must be an object from a group name to a color such as #d6eaf8"); + const statusNames = new Set( + [ + "Works", + "Works today", + "Breaks a rule", + "Still wrong", + "Fixed", + ...Object.values(LEGEND).map(([, t]) => t), + ].map((t) => t.toLowerCase()), + ); + const found = new Set([...groupsOf.values()].flatMap((gs) => [...gs]).filter(Boolean)); + for (const g of found) + if (statusNames.has(g.toLowerCase())) + throw new Error( + `group "${g}" has the name of a status; TLC decides the status, so name the group for what the reader sees`, + ); + groupNames.push( + ...Object.keys(given).filter((g) => found.has(g)), + ...[...found].filter((g) => !Object.hasOwn(given, g)).sort(), + ); + for (const [g, color] of Object.entries(given)) { + if (color === undefined || color === null || color === "") continue; + const why = groupColorProblem(color); + if (why) throw new Error(`groupColors["${g}"] ${JSON.stringify(color)} ${why}`); + if (found.has(g)) fills.set(g, color.toLowerCase()); + } + const free = GROUP_PALETTE.filter((c) => ![...fills.values()].includes(c)); + for (const g of groupNames) { + if (fills.has(g)) continue; + if (!free.length) + throw new Error( + `the mapping has ${groupNames.length} groups, more than the ${GROUP_PALETTE.length} colors of the palette; give the others a color in groupColors`, + ); + fills.set(g, free.shift()); + } +} +const mixedGroups = [...groupsOf].filter(([, gs]) => gs.size > 1).map(([k]) => k); + +const BORDER = { works: "", wrong: "Wrong", fixed: "Fixed" }; +const classFor = (g, s, path) => + grouping + ? `${g ? `group${groupNames.indexOf(g)}` : "plain"}${BORDER[s] || (path ? "Path" : "")}` + : `${s}${path ? "Path" : ""}`; +const drawnState = (g, s, path = false) => ({ group: g, status: s, cls: classFor(g, s, path) }); +const groupCounts = (drawn) => + groupNames + .map((g) => ({ + name: g, + color: fills.get(g), + states: drawn.filter((d) => d.group === g).length, + })) + .filter((g) => g.states > 0); + +// Mermaid shrinks a chart to the page width unless useMaxWidth is false, and rewraps labels that +// wrap() already wrapped unless markdownAutoWrap is false. +const frontMatter = (title) => [ + "---", + ...(title ? [`title: ${JSON.stringify(title)}`] : []), + "config:", + " markdownAutoWrap: false", + " state:", + " useMaxWidth: false", + "---", +]; +function head(drawn) { + const out = [...frontMatter(args.title), "stateDiagram-v2"]; + if (role) out.push(` %% second model (${role}): ${L}`); + if (args.fullLabels) out.push(` ${FULL_LABELS}`); + const used = Object.keys(LEGEND).filter((s) => drawn.some((d) => d.status === s)); + const legend = grouping + ? [ + ...groupCounts(drawn).map(({ name }) => { + const i = groupNames.indexOf(name); + return [`legendGroup${i}`, name, `group${i}`]; + }), + ...used.filter((s) => BORDER[s]).map((s) => [...LEGEND[s], `plain${BORDER[s]}`]), + ] + : used.map((s) => [...LEGEND[s], s]); + if (grouping) { + const styleClasses = new Set([...drawn.map((d) => d.cls), ...legend.map(([, , c]) => c)]); + const bases = [["plain", PLAIN_FILL], ...groupNames.map((g, i) => [`group${i}`, fills.get(g)])]; + for (const [base, fill] of bases) + for (const border of ["", "Wrong", "Fixed", "Path"]) + if (styleClasses.has(base + border)) out.push(` ${groupClassDef(base, fill, border)}`); + } else for (const c of STATUS_CLASSES) out.push(` ${c}`); + if (legend.length) { + out.push(` state "Legend" as ${LEGEND_ID} {`); + for (const [id, text] of legend) out.push(` state "${quote(text)}" as ${id}`); + out.push(" }"); + for (const [id, , c] of legend) out.push(` class ${id} ${c}`); + } + return out; +} + +function regionsChart({ states, starts, edges, classes, proofPath, allPath }) { + const norm = (s) => s.replaceAll(";", ",").replace(/\s+/g, " ").trim(); + const split = (k) => { + const at = k.indexOf(": "); + return at > 0 && at + 2 < k.length ? [norm(k.slice(0, at)), norm(k.slice(at + 2))] : null; + }; + const nodes = [...states].sort(); + if (!nodes.every(split)) return null; + const steps = []; + for (const e of edges.values()) { + if (e.labels.size === 0) return { why: "a step has no label" }; + for (const l of e.labels) + steps.push({ + from: split(e.from), + to: split(e.to), + label: norm(l), + words: e.guards?.get(l) ?? new Set([""]), + }); + } + const bar = (p) => p.join(" | "); + const flat = { + Main: { + states: new Set(nodes.map((k) => bar(split(k)))), + initial: new Set(starts.map((k) => bar(split(k)))), + transitions: steps.map((s) => ({ from: bar(s.from), to: bar(s.to), label: s.label })), + }, + }; + const ids = new Map([...flat.Main.states].map((s, i) => [s, `p${i}`])); + const proof = [ + ...frontMatter(), + "stateDiagram-v2", + ...[...ids].map(([s, id]) => ` state "${quote(s)}" as ${id}`), + ...[...flat.Main.initial].map((s) => ` [*] --> ${ids.get(s)}`), + ...steps.map((s) => ` ${ids.get(bar(s.from))} --> ${ids.get(bar(s.to))} : ${s.label}`), + ]; + if (proofPath) fs.writeFileSync(proofPath, proof.join("\n") + "\n"); + + const beside = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + if (!beside[i].has(p[i])) beside[i].set(p[i], new Set()); + beside[i].get(p[i]).add(p[1 - i]); + } + } + const size = beside[0].size + beside[1].size; + if (size >= nodes.length) + return { why: `${size} region states are not fewer than the ${nodes.length} flat states` }; + + const rank = ["works", "fixed", "wrong"]; + const regionStatus = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionStatus[i].get(p[i]); + if (!prev || rank.indexOf(status(k)) < rank.indexOf(prev)) + regionStatus[i].set(p[i], status(k)); + } + } + for (const k of nodes) { + const p = split(k); + const shown = rank[Math.max(...[0, 1].map((i) => rank.indexOf(regionStatus[i].get(p[i]))))]; + if (shown !== status(k)) + return { + why: `"${k}" is "${LEGEND[status(k)][1]}", and its two region states would show "${LEGEND[shown][1]}"`, + }; + } + + const moves = [new Map(), new Map()]; + for (const s of steps) + for (const i of [0, 1]) { + const id = [s.from[i], s.label, s.to[i]].join(SEP); + if (!moves[i].has(id)) + moves[i].set(id, { + from: s.from[i], + label: s.label, + to: s.to[i], + beside: new Set(), + guards: new Map(), + }); + moves[i].get(id).beside.add(s.from[1 - i]); + for (const w of s.words) addGuard(moves[i].get(id).guards, s.label, norm(w)); + } + for (const m of moves) + for (const [id, x] of m) + if ( + x.from === x.to && + ![...m.values()].some((y) => y.from === x.from && y.label === x.label && y.to !== x.from) + ) + m.delete(id); + const rid = beside.map((m, i) => new Map([...m.keys()].sort().map((s, j) => [s, `r${i}s${j}`]))); + const regionGroup = [new Map(), new Map()]; + for (const k of nodes) { + const p = split(k); + for (const i of [0, 1]) { + const prev = regionGroup[i].get(p[i]); + regionGroup[i].set(p[i], prev === undefined || prev === groupOf(k) ? groupOf(k) : ""); + } + } + const drawn = rid.map((m, i) => + [...m.keys()].map((s) => drawnState(regionGroup[i].get(s), regionStatus[i].get(s))), + ); + const draw = (only) => { + const out = head(drawn.flat()); + out.push(` state "At the same time" as regions {`); + let count = 0; + for (const i of [0, 1]) { + if (i) out.push(" --"); + const name = mapping.regionNames?.[i]; + const pad = name ? " " : " "; + if (name) out.push(` state "${quote(wrap(name))}" as region${i} {`); + for (const s of new Set(starts.map((k) => split(k)[i]))) + out.push(`${pad}[*] --> ${rid[i].get(s)}`); + for (const [s, id] of rid[i]) out.push(`${pad}state "${quote(wrap(s))}" as ${id}`); + const shownMoves = [...moves[i]] + .filter(([id]) => !only || only[i].has(id)) + .map(([, m]) => m) + .sort((a, b) => a.from.localeCompare(b.from) || a.label.localeCompare(b.label)); + for (const m of shownMoves) { + const all = beside[i].get(m.from); + const not = [...all].filter((s) => !m.beside.has(s)).sort(); + const yes = [...m.beside].sort(); + const unless = not.length > 0 && not.length < yes.length; + const guard = not.length === 0 ? null : unless ? not : yes; + const worded = guarded(m.guards, m.label); + const text = + worded !== m.label || !guard + ? worded + : `${m.label} [${unless ? "unless" : "when"} ${guard.join(" or ")}]`; + out.push(`${pad}${rid[i].get(m.from)} --> ${rid[i].get(m.to)} : ${wrap(text, 30)}`); + if (guard) + out.push( + `${pad}%% when ${unless ? "not " : ""}${guard.map((s) => rid[1 - i].get(s)).join(", ")}`, + ); + count++; + } + if (name) out.push(" }"); + } + out.push(" }"); + for (const i of [0, 1]) + [...rid[i].values()].forEach((id, j) => out.push(` class ${id} ${drawn[i][j].cls}`)); + return { mermaid: out.join("\n") + "\n", count }; + }; + + const all = draw(null); + const differences = compareCharts(parseMermaid(all.mermaid).regions, flat); + if (differences.length) { + const n = differences.reduce((sum, [, xs]) => sum + xs.length, 0); + return { + why: `the regions do not combine into the flat chart, with ${n} difference(s), such as ${differences[0][0]}: ${differences[0][1][0]}`, + }; + } + if (allPath) fs.writeFileSync(allPath, all.mermaid); + + const parts = new Map(nodes.map((k) => [bar(split(k)), split(k)])); + const key = (from, label, to) => `${from} --> ${to} : ${label}`; + const real = new Set(steps.map((s) => key(bar(s.from), s.label, bar(s.to)))); + const next = new Map(); + for (const s of steps) { + if (!next.has(bar(s.from))) next.set(bar(s.from), []); + next.get(bar(s.from)).push(s); + } + const keep = [new Set(), new Set()]; + const movesOf = (s) => + [0, 1] + .filter((i) => s.from[i] !== s.to[i]) + .map((i) => [i, [s.from[i], s.label, s.to[i]].join(SEP)]); + const price = (s) => movesOf(s).filter(([i, id]) => !keep[i].has(id)).length * nodes.length + 1; + const wanted = []; + const want = (s) => { + wanted.push(s); + for (const [i, id] of movesOf(s)) keep[i].add(id); + }; + const stepsOf = (e, said) => { + const labels = [...e.labels].map(norm); + return (labels.includes(said) ? [said] : labels).map((label) => ({ + from: split(e.from), + to: split(e.to), + label, + })); + }; + for (const c of classes) { + for (const step of edgesAlong(c)) { + const e = edges.get(step.id); + if (e) stepsOf(e, norm(step.label)).forEach(want); + } + if (diverges(c)) stepsOf(edges.get(c.second.edge), "").forEach(want); + } + const cheapest = () => { + const cost = new Map(starts.map((k) => [bar(split(k)), 0])); + const parent = new Map(); + const open = new Set(cost.keys()); + while (open.size) { + const k = [...open].reduce((a, b) => (cost.get(b) < cost.get(a) ? b : a)); + open.delete(k); + for (const s of next.get(k) ?? []) { + const c = cost.get(k) + price(s); + if (c < (cost.get(bar(s.to)) ?? Infinity)) { + cost.set(bar(s.to), c); + parent.set(bar(s.to), s); + open.add(bar(s.to)); + } + } + } + return { cost, parent }; + }; + const targets = [ + ...[0, 1].flatMap((i) => + [...beside[i].keys()].sort().map((r) => ({ at: (k) => parts.get(k)[i] === r })), + ), + ...[...new Set(steps.map((s) => s.label))] + .filter((l) => !wanted.some((s) => s.label === l)) + .sort() + .map((l) => ({ ends: (s) => s.label === l })), + ]; + while (targets.length) { + const { cost, parent } = cheapest(); + let best = null; + targets.forEach((t, n) => { + const ways = t.at + ? [...cost.keys()].filter(t.at).map((k) => ({ k, c: cost.get(k), last: null })) + : steps + .filter(t.ends) + .filter((s) => cost.has(bar(s.from))) + .map((s) => ({ k: bar(s.from), c: cost.get(bar(s.from)) + price(s), last: s })); + for (const w of ways) if (!best || w.c < best.c) best = { ...w, n }; + }); + if (!best) break; + targets.splice(best.n, 1); + const path = best.last ? [best.last] : []; + for (let s = parent.get(best.k); s; s = parent.get(bar(s.from))) path.push(s); + path.forEach(want); + } + const takes = (x, label) => + [0, 1].flatMap((i) => + [...moves[i]] + .filter(([, m]) => m.from === x[i] && m.label === label && m.beside.has(x[1 - i])) + .map(([id]) => [i, id]), + ); + let scoped; + let made; + for (;;) { + scoped = draw(keep); + made = productOf(parseMermaid(scoped.mermaid).regions); + const madeKeys = new Set(made.transitions.map((t) => key(t.from, t.label, t.to))); + const redo = [ + ...made.transitions + .filter((t) => parts.has(t.from) && !real.has(key(t.from, t.label, t.to))) + .map((t) => [parts.get(t.from), t.label]), + ...wanted + .filter((s) => made.states.has(bar(s.from))) + .filter((s) => !madeKeys.has(key(bar(s.from), s.label, bar(s.to)))) + .map((s) => [s.from, s.label]), + ]; + const before = keep[0].size + keep[1].size; + for (const [x, label] of redo) for (const [i, id] of takes(x, label)) keep[i].add(id); + if (keep[0].size + keep[1].size === before) break; + } + const sound = + [...made.states].every((s) => parts.has(s)) && + made.transitions.every((t) => real.has(key(t.from, t.label, t.to))); + if (!sound) scoped = all; + + return { + mermaid: scoped.mermaid, + states: [beside[0].size, beside[1].size], + transitions: scoped.count, + all: all.count, + flat: { states: nodes.length, transitions: steps.length }, + status: Object.fromEntries( + rank.map((s) => [ + s, + regionStatus.reduce((n, m) => n + [...m.values()].filter((x) => x === s).length, 0), + ]), + ), + groups: groupCounts(drawn.flat()), + }; +} +const regions = args.regions + ? regionsChart({ + states: allNodes, + starts: [...initial], + edges, + classes, + proofPath: args.proof, + allPath: args.regionsAll, + }) + : null; +if (regions?.why) console.error(`regions: not drawn, because ${regions.why}`); + +const flatStates = + args.pathOnly && chosen + ? onPath + : coverEdges + ? new Set([ + ...initial, + ...[...coverEdges].flatMap((id) => [edges.get(id).from, edges.get(id).to]), + ]) + : allNodes; +const sorted = [...flatStates].sort(); +const drawn = sorted.map((k) => drawnState(groupOf(k), status(k), onPath.has(k))); + +const boxes = new Map(); +const boxOf = new Map(); +const inBox = (k, box) => { + if (!boxes.has(box)) boxes.set(box, []); + boxes.get(box).push(k); +}; +if (args.view === "layered") for (const k of sorted) inBox(k, k.split(SEP)[0]); +else if (args.view === "customer" && !args.flat) { + for (const k of sorted) { + const at = k.indexOf(": "); + if (at > 0 && at + 2 < k.length) inBox(k, k.slice(0, at)); + } + for (const [box, members] of boxes) if (members.length < 2) boxes.delete(box); +} +const boxIds = new Map([...boxes.keys()].map((box, i) => [box, `g${i}`])); +for (const [box, members] of boxes) for (const k of members) boxOf.set(k, box); +const declared = [...[...boxes.values()].flat(), ...sorted.filter((k) => !boxOf.has(k))]; +const stateIds = new Map(declared.map((k, i) => [k, `s${i}`])); +const idOf = (k) => stateIds.get(k); +const caption = (k) => + args.view === "layered" ? k.split(SEP)[1] : boxOf.has(k) ? k.slice(boxOf.get(k).length + 2) : k; + +function numbered(steps) { + const runs = []; + for (const { n, shown } of steps) { + const last = runs.at(-1); + if (last?.shown === shown && last.n.at(-1) === n - 1) last.n.push(n); + else runs.push({ shown, n: [n] }); + } + return runs.map(({ shown, n }) => `${n.join(", ")}. ${shown}`); +} +const drawnLabels = new Set(); +const arrows = new Map(); +for (const [id, e] of edges) { + if (!flatStates.has(e.from) || !flatStates.has(e.to)) continue; + if (args.pathOnly && chosen && !pathNumbers.has(id) && id !== chosen.second?.edge) continue; + if (coverEdges && !coverEdges.has(id)) continue; + const pair = edgeId(e.from, e.to); + if (!arrows.has(pair)) + arrows.set(pair, { from: e.from, to: e.to, groups: [[], [], []], plain: true, labels: [] }); + const a = arrows.get(pair); + const [numbers, current, other] = a.groups; + const all = [...e.labels]; + const shown = all.map((l) => guarded(e.guards, l)); + const said = args.fullLabels ? shown.join(" or ") : joinLabels(shown); + const p = pathNumbers.get(id); + const chosenStep = chosen?.second && id === chosen.second.edge; + if (p) numbers.push(...numbered(p)); + else if (!e.secondOnly) current.push(said); + else other.push(args.pathOnly && chosenStep ? chosen.second.text : said); + if (chosenStep && !e.secondOnly) other.push(chosen.second.text); + if (p || chosenStep || id.endsWith(`${SEP}second`)) a.plain = false; + a.labels.push(...all); + for (const l of p ? p.map((s) => s.label) : all) drawnLabels.add(l); +} +const transitions = [...arrows.values()].map(({ from, to, groups, plain, labels }) => ({ + from, + to, + // ";" ends a Mermaid statement, even inside a transition label. + text: groups + .map((g) => + g + .filter(Boolean) + .map((l) => wrap(l.replaceAll(";", ","), 30)) + .join("
"), + ) + .filter(Boolean) + .join("
#160;
"), + plain, + labels: labels.sort(), +})); +const fromBox = []; +const folded = new Set(); +for (const [box, members] of boxes) { + const mine = new Set(members); + const byStep = new Map(); + for (const l of transitions) { + if (!l.plain || !mine.has(l.from) || mine.has(l.to)) continue; + const step = `${l.to}${SEP}${l.text}${SEP}${l.labels.join(SEP)}`; + if (!byStep.has(step)) byStep.set(step, []); + byStep.get(step).push(l); + } + for (const same of byStep.values()) { + if (new Set(same.map((l) => l.from)).size !== members.length) continue; + for (const l of same) folded.add(l); + fromBox.push({ ...same[0], box }); + } +} + +const out = head(drawn); +for (const [box, members] of boxes) { + out.push(` state "${quote(wrap(box))}" as ${boxIds.get(box)} {`); + for (const k of members) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); + out.push(" }"); +} +for (const k of sorted) + if (!boxOf.has(k)) out.push(` state "${quote(wrap(caption(k)))}" as ${idOf(k)}`); +for (const k of sorted) + if (initial.has(k) && (!args.pathOnly || k === chosen?.path[0].key)) + out.push(` [*] --> ${idOf(k)}`); +const arrow = (from, l) => ` ${from} --> ${idOf(l.to)}${l.text ? ` : ${l.text}` : ""}`; +for (const l of fromBox) out.push(arrow(boxIds.get(l.box), l)); +for (const l of transitions) if (!folded.has(l)) out.push(arrow(idOf(l.from), l)); +sorted.forEach((k, i) => out.push(` class ${idOf(k)} ${drawn[i].cls}`)); +const mermaid = regions?.mermaid ?? out.join("\n") + "\n"; +if (args.out) fs.writeFileSync(args.out, mermaid); +else process.stdout.write(mermaid); + +const show = (k) => (args.view === "layered" ? k.replace(SEP, ": ") : k); +const ruleOf = (c) => rules.get(c.invariant) ?? `\`${c.invariant}\``; +function secondLine(c) { + const W = cap(within); + if (c.introduced) return `Only ${L} has this bug. The steps above happen ${within} ${L}.`; + const s = c.second; + if (!s) return null; + if (s.sameEnd) + return s.ruleHolds + ? `${W} ${L} you see the same screens and steps, yet the rule holds, so the state labels do not show what ${L} changes.` + : `${W} ${L} you see the same screens and steps, so ${L} does not change this path.`; + if (s.reason) return `${cap(L)} cannot take these steps: it ${s.reason}.`; + const outcome = s.disabled + ? "cannot happen, so you stay at" + : s.key === s.from + ? "keeps you at" + : "takes you to"; + if (s.hidden) { + const when = s.step ? `after step ${s.step}` : "before your first step"; + return `${W} ${L}, ${when}, "${s.said}" happens and ${outcome}: ${show(s.key)}.`; + } + return `${W} ${L}, step ${s.step} (${s.said}) ${outcome}: ${show(s.key)}.`; +} +if (args.story) { + const lines = []; + const header = role === "fix" ? `New with ${L}: ` : `Only in ${L}: `; + classes.forEach((c, i) => { + lines.push(`## ${i + 1}. ${c.introduced ? header : ""}${show(c.end)}`, ""); + lines.push(`Rule broken: ${ruleOf(c)}`); + if (c.cause) lines.push(`Why: ${c.cause.replace(/\.$/, "")}.`); + lines.push( + c.count === 1 + ? "This is the only way to get here in the model." + : `There are ${c.count} ways to get here in the model. This is the shortest.`, + "", + ); + c.path.forEach((p, j) => + lines.push( + j === 0 ? `0. Start: ${show(p.key)}` : `${j}. ${cap(p.label)}. Now: ${show(p.key)}`, + ), + ); + lines.push(""); + const said = secondLine(c); + if (said) lines.push(said, ""); + }); + fs.writeFileSync(args.story, lines.join("\n")); +} +const CODE_LIKE = /\b[a-z]{2,}[A-Z][a-z]|\b\w+_\w+\b|\b[A-Za-z_]\w*\(|\b[A-Z][a-z]+[A-Z][a-z]/; +const codeLike = [ + ...new Set([ + ...(args.view === "customer" ? [...sorted, ...drawnLabels] : []), + ...classes.map((c) => c.cause).filter(Boolean), + ]), +].filter((t) => CODE_LIKE.test(t)); +const secondOf = (c) => + c.introduced + ? role === "fix" + ? `new with ${L}` + : `only in ${L}` + : c.second + ? c.second.sameEnd + ? c.second.ruleHolds + ? "same screens, but the rule holds" + : "unchanged" + : (c.second.reason ?? show(c.second.key)) + : null; +const summary = { + view: args.view, + ...(role ? { second: { role, label: L, lacks: lacks.map((p) => p.join(".")) } } : {}), + modelStates: graph.nodes.size, + modelTransitions: graph.edges.length, + chartStates: regions?.mermaid ? regions.states[0] + regions.states[1] : flatStates.size, + chartTransitions: regions?.mermaid ? regions.transitions : transitions.length, + chartStatus: regions?.mermaid + ? regions.status + : Object.fromEntries( + ["works", "wrong", "fixed"].map((s) => [ + s, + [...flatStates].filter((k) => status(k) === s).length, + ]), + ), + ...(grouping + ? { + chartGroups: regions?.mermaid ? regions.groups : groupCounts(drawn), + mixedGroups: mixedGroups.map(show).sort(), + } + : {}), + ...(regions + ? { + regions: regions.mermaid + ? { + drawn: true, + states: regions.states, + transitions: regions.transitions, + all: regions.all, + flat: regions.flat, + } + : { drawn: false, why: regions.why }, + } + : {}), + classes: classes.map((c, i) => ({ + n: i + 1, + end: show(c.end), + invariant: c.invariant, + rule: rules.get(c.invariant) ?? null, + cause: c.cause, + readerSteps: c.path.length - 1, + modelSteps: c.trace.steps.length, + counterexamples: c.count, + [role ?? "fix"]: secondOf(c), + })), + codeLike, +}; +if (args.summary) fs.writeFileSync(args.summary, JSON.stringify(summary, null, 2) + "\n"); +const extraBugs = introduced.length + ? `, ${introduced.length} ${role === "fix" ? `new with ${L}` : `only in ${L}`}` + : ""; +const drawnAs = regions?.mermaid + ? ` of ${regions.all} in 2 regions (proved against ${regions.flat.states} states, ${regions.flat.transitions} transitions)` + : ""; +console.error( + `chart: ${summary.chartStates} states, ${summary.chartTransitions} transitions${drawnAs} from ${summary.modelStates} model states; ` + + `${mainClasses.length} bug(s)${extraBugs}`, +); diff --git a/tools/meta-mode/check-plan.test.ts b/tools/meta-mode/check-plan.test.ts new file mode 100644 index 0000000..fd966c2 --- /dev/null +++ b/tools/meta-mode/check-plan.test.ts @@ -0,0 +1,96 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { expect, test } from "bun:test"; + +const checker = join(import.meta.dir, "check-plan.mjs"); +const rule = + "Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked."; + +const validPlan = `# Demo + +## How to read this +One box is one unit of work and names the evidence. Check a box only when its evidence exists. See playbooks/. +${rule} + +## Program checklist +### Arm the program +### Spawn owners +### PR mechanics +### Verdict and merge +### Boot recipe +\`git show origin/main:\` /loop 1h status message + +## PR 1 +**Depends on.** None. +**Files.** +- [ ] Do the thing. +**Build.** +- [ ] Do the thing. +**You see.** +- [ ] Do the thing. +**Verify, unit.** ${rule} +- [ ] Check the thing. +**Verify, live.** ${rule} Ten lanes on \`example-model\` at the PR head +${Array.from({ length: 10 }, (_, i) => `- [ ] Lane ${i + 1}. Save \`lane-${i + 1}.png\`. Pass when evidence exists.`).join("\n")} +**Verify, perf.** ${rule} +- [ ] Metric. Recorded. +- [ ] Probe. Recorded. +- [ ] Baseline. Recorded. +- [ ] Rule. Recorded. +**Review gate.** None. +**Merge.** +- [ ] Merge when approved. + +## Close the program +Close. + +## Appendix Prototype evidence +Recorded. +`; + +const runPlan = (example: string, expectedExit: number) => { + const directory = mkdtempSync(join(tmpdir(), "check-plan-test-")); + const file = join(directory, "plan.md"); + writeFileSync(file, validPlan + example); + try { + const result = spawnSync("node", [checker, file], { encoding: "utf8" }); + expect(result.status).toBe(expectedExit); + return result; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}; + +test("accepts ordinary, indented, and longer closing fences", () => { + for (const indent of ["", " ", " ", " "]) + runPlan(`${indent}\`\`\`markdown\n## Example only\n${indent}\`\`\`\n`, 0); + runPlan("```markdown\n## Example only\n````\n", 0); +}); + +test("accepts nested backticks and tilde info strings", () => { + runPlan("````markdown\n```markdown\n## Example only\n```\n````\n", 0); + runPlan("~~~markdown `info`\n## Example only\n ~~~\n", 0); +}); + +test("keeps mismatched, shorter, and suffixed closers inside the fence", () => { + runPlan("````markdown\n~~~\n## Example only\n````\n", 0); + runPlan("````markdown\n```\n## Example only\n````\n", 0); + runPlan("````markdown\n``` info\n## Example only\n````\n", 0); +}); + +test("rejects headings and prose outside a proper close", () => { + for (const suffix of [" ", "\t"]) { + const heading = runPlan(`\`\`\`markdown\n## Example only\n\`\`\`${suffix}\n## Example only\n`, 1); + expect(heading.stderr).toContain('"## Example only" after Close the program is not an appendix'); + } + + const prose = runPlan("```markdown\nThis prose has a mid-sentence colon: inside code\n```\nThis prose has a mid-sentence colon: bad.\n", 1); + expect(prose.stderr).toContain("mid-sentence colon"); +}); + +test("rejects backtick info strings containing a backtick", () => { + const result = runPlan("```markdown `invalid`\n## Example only\n```\n", 1); + expect(result.stderr).toContain('"## Example only" after Close the program is not an appendix'); +}); From 92cb2c72a74ff2e2905b8e25a3bfda5c1c4697fd Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:46:22 +0800 Subject: [PATCH 2/6] fix(ci): align upstream validation and runtime test --- .github/workflows/validate.yml | 2 +- profiles/skill-manifest.json | 2 +- tools/meta-mode/check-plan.test.ts | 106 +++++------------------------ 3 files changed, 18 insertions(+), 92 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0b99030..df30d67 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: repository: cursor/plugins - ref: 11713c683fe4add4fd403ea880580abbc9e0875f + ref: eb4db2b55b2b7dec3d9282754cddf0a56bf4ff10 path: .upstream/cursor-plugins sparse-checkout: pstack - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 652c7c3..21741ab 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -872,7 +872,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/scripts/check-plan.test.ts", "sourceDigest": "c80f3ea043b81f219b92c4c8a3af6bdd4f8d32ea596ec15b5b9e0252645f2ae8", - "targetDigest": "c80f3ea043b81f219b92c4c8a3af6bdd4f8d32ea596ec15b5b9e0252645f2ae8", + "targetDigest": "a38f1de2a1a147c523076318584a6bc919e23ee65d88c9e32ab7cf50a08c124e", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/check-playbooks.mjs": { diff --git a/tools/meta-mode/check-plan.test.ts b/tools/meta-mode/check-plan.test.ts index fd966c2..830fb10 100644 --- a/tools/meta-mode/check-plan.test.ts +++ b/tools/meta-mode/check-plan.test.ts @@ -1,96 +1,22 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -import { expect, test } from "bun:test"; +import { test } from "bun:test"; const checker = join(import.meta.dir, "check-plan.mjs"); -const rule = - "Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked."; - -const validPlan = `# Demo - -## How to read this -One box is one unit of work and names the evidence. Check a box only when its evidence exists. See playbooks/. -${rule} - -## Program checklist -### Arm the program -### Spawn owners -### PR mechanics -### Verdict and merge -### Boot recipe -\`git show origin/main:\` /loop 1h status message - -## PR 1 -**Depends on.** None. -**Files.** -- [ ] Do the thing. -**Build.** -- [ ] Do the thing. -**You see.** -- [ ] Do the thing. -**Verify, unit.** ${rule} -- [ ] Check the thing. -**Verify, live.** ${rule} Ten lanes on \`example-model\` at the PR head -${Array.from({ length: 10 }, (_, i) => `- [ ] Lane ${i + 1}. Save \`lane-${i + 1}.png\`. Pass when evidence exists.`).join("\n")} -**Verify, perf.** ${rule} -- [ ] Metric. Recorded. -- [ ] Probe. Recorded. -- [ ] Baseline. Recorded. -- [ ] Rule. Recorded. -**Review gate.** None. -**Merge.** -- [ ] Merge when approved. - -## Close the program -Close. - -## Appendix Prototype evidence -Recorded. -`; - -const runPlan = (example: string, expectedExit: number) => { - const directory = mkdtempSync(join(tmpdir(), "check-plan-test-")); - const file = join(directory, "plan.md"); - writeFileSync(file, validPlan + example); - try { - const result = spawnSync("node", [checker, file], { encoding: "utf8" }); - expect(result.status).toBe(expectedExit); - return result; - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}; - -test("accepts ordinary, indented, and longer closing fences", () => { - for (const indent of ["", " ", " ", " "]) - runPlan(`${indent}\`\`\`markdown\n## Example only\n${indent}\`\`\`\n`, 0); - runPlan("```markdown\n## Example only\n````\n", 0); -}); - -test("accepts nested backticks and tilde info strings", () => { - runPlan("````markdown\n```markdown\n## Example only\n```\n````\n", 0); - runPlan("~~~markdown `info`\n## Example only\n ~~~\n", 0); -}); - -test("keeps mismatched, shorter, and suffixed closers inside the fence", () => { - runPlan("````markdown\n~~~\n## Example only\n````\n", 0); - runPlan("````markdown\n```\n## Example only\n````\n", 0); - runPlan("````markdown\n``` info\n## Example only\n````\n", 0); -}); - -test("rejects headings and prose outside a proper close", () => { - for (const suffix of [" ", "\t"]) { - const heading = runPlan(`\`\`\`markdown\n## Example only\n\`\`\`${suffix}\n## Example only\n`, 1); - expect(heading.stderr).toContain('"## Example only" after Close the program is not an appendix'); - } - - const prose = runPlan("```markdown\nThis prose has a mid-sentence colon: inside code\n```\nThis prose has a mid-sentence colon: bad.\n", 1); - expect(prose.stderr).toContain("mid-sentence colon"); -}); - -test("rejects backtick info strings containing a backtick", () => { - const result = runPlan("```markdown `invalid`\n## Example only\n```\n", 1); - expect(result.stderr).toContain('"## Example only" after Close the program is not an appendix'); +const playbook = readFileSync(join(import.meta.dir, "../../skills/meta-mode/playbooks/multi-phase-plan.md"), "utf8"); +const skeleton = playbook.match(/^````markdown\n([\s\S]*?)^````$/m)?.[1]; +if (!skeleton) throw new Error("multi-phase playbook has no plan skeleton"); + +test("the bundled mstack plan skeleton passes the checker", () => { + const directory = mkdtempSync(join(tmpdir(), "check-plan-test-")); + const file = join(directory, "plan.md"); + try { + writeFileSync(file, skeleton); + const result = spawnSync(process.execPath, [checker, file], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`${result.stdout}${result.stderr}`); + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); From 6af31cf0cca53427d7124b2587e9051b6be8a432 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:56:47 +0800 Subject: [PATCH 3/6] fix(upstream): preserve statechart fixture bytes --- profiles/skill-manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 21741ab..083ed57 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -660,7 +660,7 @@ "upstream": "pstack", "source": "skills/statechart/scripts/model/OutputFolder.cfg", "sourceDigest": "08529388d6b9cc4519593ae2a055af27bac005c99fa1e21cd6703c706e861f23", - "targetDigest": "8b9662ab7d51de9d54f84abab02888822da9c01ce4f1b86a6453a8a513773995" + "targetDigest": "08529388d6b9cc4519593ae2a055af27bac005c99fa1e21cd6703c706e861f23" }, "skills/statechart/scripts/model/OutputFolder.map.mjs": { "upstream": "pstack", @@ -672,7 +672,7 @@ "upstream": "pstack", "source": "skills/statechart/scripts/model/OutputFolder.tla", "sourceDigest": "6bed8d69f3ab817865e7271986c47adab5ed170c127aa7b83017b71f5fd30640", - "targetDigest": "75bef6811d9a8e56bd702fd8d782e52101918c8dc05c6cc9dc43d02b9fe1b56f" + "targetDigest": "6bed8d69f3ab817865e7271986c47adab5ed170c127aa7b83017b71f5fd30640" }, "skills/statechart/scripts/statechart.mjs": { "upstream": "pstack", @@ -690,13 +690,13 @@ "upstream": "pstack", "source": "skills/statechart/scripts/test/sync.mmd", "sourceDigest": "7a6167157bd1482f31a9e123547e7146d5928dafd3a05b00fc40a21438f28b00", - "targetDigest": "cb1f5387058a0f79d6187eb217690588a2e2d384637b4d53cbc4bb050c570757" + "targetDigest": "7a6167157bd1482f31a9e123547e7146d5928dafd3a05b00fc40a21438f28b00" }, "skills/statechart/scripts/test/upload.mmd": { "upstream": "pstack", "source": "skills/statechart/scripts/test/upload.mmd", "sourceDigest": "99b100520e33ccc9f2343cc0b7edcd135b9351219633ff59f3560c79497081e6", - "targetDigest": "bbc1fa70e26cbcbcc7cde5d8fb7eda26df34b6d973053bf13c40b18527ff0e76" + "targetDigest": "99b100520e33ccc9f2343cc0b7edcd135b9351219633ff59f3560c79497081e6" }, "skills/statechart/scripts/tlc-to-chart.mjs": { "upstream": "pstack", From 1c55a910c70a5c17a01c07aa782932dee6d508a0 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:09:31 +0800 Subject: [PATCH 4/6] fix(upstream): normalize statechart provenance bytes --- profiles/skill-manifest.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 083ed57..620469f 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -659,8 +659,8 @@ "skills/statechart/scripts/model/OutputFolder.cfg": { "upstream": "pstack", "source": "skills/statechart/scripts/model/OutputFolder.cfg", - "sourceDigest": "08529388d6b9cc4519593ae2a055af27bac005c99fa1e21cd6703c706e861f23", - "targetDigest": "08529388d6b9cc4519593ae2a055af27bac005c99fa1e21cd6703c706e861f23" + "sourceDigest": "8b9662ab7d51de9d54f84abab02888822da9c01ce4f1b86a6453a8a513773995", + "targetDigest": "8b9662ab7d51de9d54f84abab02888822da9c01ce4f1b86a6453a8a513773995" }, "skills/statechart/scripts/model/OutputFolder.map.mjs": { "upstream": "pstack", @@ -671,8 +671,8 @@ "skills/statechart/scripts/model/OutputFolder.tla": { "upstream": "pstack", "source": "skills/statechart/scripts/model/OutputFolder.tla", - "sourceDigest": "6bed8d69f3ab817865e7271986c47adab5ed170c127aa7b83017b71f5fd30640", - "targetDigest": "6bed8d69f3ab817865e7271986c47adab5ed170c127aa7b83017b71f5fd30640" + "sourceDigest": "75bef6811d9a8e56bd702fd8d782e52101918c8dc05c6cc9dc43d02b9fe1b56f", + "targetDigest": "75bef6811d9a8e56bd702fd8d782e52101918c8dc05c6cc9dc43d02b9fe1b56f" }, "skills/statechart/scripts/statechart.mjs": { "upstream": "pstack", @@ -689,14 +689,14 @@ "skills/statechart/scripts/test/sync.mmd": { "upstream": "pstack", "source": "skills/statechart/scripts/test/sync.mmd", - "sourceDigest": "7a6167157bd1482f31a9e123547e7146d5928dafd3a05b00fc40a21438f28b00", - "targetDigest": "7a6167157bd1482f31a9e123547e7146d5928dafd3a05b00fc40a21438f28b00" + "sourceDigest": "cb1f5387058a0f79d6187eb217690588a2e2d384637b4d53cbc4bb050c570757", + "targetDigest": "cb1f5387058a0f79d6187eb217690588a2e2d384637b4d53cbc4bb050c570757" }, "skills/statechart/scripts/test/upload.mmd": { "upstream": "pstack", "source": "skills/statechart/scripts/test/upload.mmd", - "sourceDigest": "99b100520e33ccc9f2343cc0b7edcd135b9351219633ff59f3560c79497081e6", - "targetDigest": "99b100520e33ccc9f2343cc0b7edcd135b9351219633ff59f3560c79497081e6" + "sourceDigest": "bbc1fa70e26cbcbcc7cde5d8fb7eda26df34b6d973053bf13c40b18527ff0e76", + "targetDigest": "bbc1fa70e26cbcbcc7cde5d8fb7eda26df34b6d973053bf13c40b18527ff0e76" }, "skills/statechart/scripts/tlc-to-chart.mjs": { "upstream": "pstack", From 5f16b2f7111965a8653e778bdb6e73f565817997 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:22:27 +0800 Subject: [PATCH 5/6] fix(review): align guides and statechart portability --- docs/guide/01-setup.md | 2 +- docs/guide/06-verify-and-ship.md | 2 +- docs/guide/07-overnight.md | 2 +- profiles/skill-manifest.json | 4 ++-- profiles/upstream-manifest.json | 8 ++++---- scripts/skill-baseline-lib.mjs | 2 +- skills/setup-mstack/SKILL.md | 10 ++++++++++ skills/statechart/scripts/statechart.mjs | 2 +- 8 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/guide/01-setup.md b/docs/guide/01-setup.md index b66ea8d..985dd61 100644 --- a/docs/guide/01-setup.md +++ b/docs/guide/01-setup.md @@ -13,7 +13,7 @@ npx @3metajun/mstack --harness The installer reports the target directory and the number of skills it staged. Run it again for another Harness when you use more than one. -In Claude Code, run `/plugin marketplace add cursor/plugins`, then `/plugin install mstack@cursor-plugins`. Claude Code prefixes plugin commands, so each command in this guide takes a `mstack:` prefix there, such as `/mstack:setup-mstack`, which writes its model rule to `~/.claude/rules/mstack-models.md`. The [Claude Code adapter](../harness/claude-code.md) covers the other differences. +Claude Code can load mstack through the installer command above. Its skill root and frontmatter rules differ from the shared Harness copy; see the [Harness adapter reference](../harness-adapters.md). ## Pick your models diff --git a/docs/guide/06-verify-and-ship.md b/docs/guide/06-verify-and-ship.md index 3292e53..62089db 100644 --- a/docs/guide/06-verify-and-ship.md +++ b/docs/guide/06-verify-and-ship.md @@ -58,7 +58,7 @@ The UI bullet above hides a real requirement. The agent needs a scripted way to [`/create-verification-skill`](../../skills/create-verification-skill/SKILL.md) interviews the repository, not you. It works out what a user touches, how the app launches locally, what can drive it (an existing harness first, otherwise browser and CDP, a PTY, or plain HTTP), what evidence proves behavior, and whether two instances can run side by side. It asks you only what the code can't answer. -It writes `.cursor/skills/verify-/`, agent-facing instructions with exact Launch, Doctor, Drive, Evidence, and Cleanup sections, a Helpers section for any script it ships, plus a feature map under `features/` that indexes what the app does and what result proves each feature works. The skill ships a [worked feature-map example](../../skills/create-verification-skill/references/feature-map-example/) with a README index and one file per feature using the four required H2s. Before handing it over, the generator proves the skill once end to end: launch, doctor check, drive one feature, capture evidence, clean up, then confirm the evidence survived the cleanup. If that proof fails, don't use the output. +It writes `verify-/` under the active Harness's project skill root, with exact Launch, Doctor, Drive, Evidence, and Cleanup sections, a Helpers section for any script it ships, plus a feature map under `features/` that indexes what the app does and what result proves each feature works. See the [project skill paths](../../skills/create-verification-skill/references/harness-paths.md). The skill ships a [worked feature-map example](../../skills/create-verification-skill/references/feature-map-example/) with a README index and one file per feature using the four required H2s. Before handing it over, the generator proves the skill once end to end: launch, doctor check, drive one feature, capture evidence, clean up, then confirm the evidence survived the cleanup. If that proof fails, don't use the output. From then on, "verify it in the app" is a step any agent can execute, in this repo, with no setup conversation. Name it in the prompt when you want the proof in a specific form: diff --git a/docs/guide/07-overnight.md b/docs/guide/07-overnight.md index e24bd55..6558b40 100644 --- a/docs/guide/07-overnight.md +++ b/docs/guide/07-overnight.md @@ -73,7 +73,7 @@ For a quick status instead of an audit, run [`/ketchup`](../../skills/ketchup/SK The contract above drives one task to one finish condition. Some nights hold more, a queue of independent changes or a whole program. Three playbooks scale the same trust up. -[Autopilot-full](../../skills/meta-mode/playbooks/autopilot-full.md) runs a queue of independent PRs to merged. Each PR gets one owner agent that carries it from build through merge, and no owner merges on its own verdict. A swarm of fresh verifiers checks the owner's code-ready head. A later rebase or minor fix gets one fresh verifier, and only new behavior or a large new diff calls the full swarm again. Only a clean verdict on the patch that merges authorizes the merge: +[Autopilot-full](../../skills/meta-mode/playbooks/autopilot-full.md) runs a queue of independent PRs to merged. Each PR gets one owner agent that carries it from build through merge, and no owner merges on its own verdict. A swarm of fresh verifiers checks the owner's code-ready head. A later push that changes the patch starts a fresh verification round. The verifier may reuse the prior evidence only when the patch identity and verification rule still match; otherwise run the full swarm again. Only a clean verdict on the patch that merges authorizes the merge: ```text /meta-mode full autopilot on this queue. each item is independent. i want them merged by morning. diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 620469f..dde41f8 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -611,7 +611,7 @@ "upstream": "pstack", "source": "skills/setup-pstack/SKILL.md", "sourceDigest": "b45512f6ca5e96e81681984683b7472bfe892581da3a89a73cca4159438f29ed", - "targetDigest": "ccc9c0c029bc15fcce99a3983cf0c9d461f480d704227fb814396958cb8ffa7a" + "targetDigest": "f78da4632da25656622e4d28485cea8a7eb1b4bdab234051d3d18ab64a2421dd" }, "skills/sharpen/SKILL.md": { "upstream": "pstack", @@ -678,7 +678,7 @@ "upstream": "pstack", "source": "skills/statechart/scripts/statechart.mjs", "sourceDigest": "d352ca68fb05cef8673ce1b86ea516cc619ff6c1f4faea70233de59bcdf7a9b7", - "targetDigest": "d352ca68fb05cef8673ce1b86ea516cc619ff6c1f4faea70233de59bcdf7a9b7" + "targetDigest": "722442dfac9ad53c5b0b4c81099310d3ce3abbdc22e7bb71091a8ab9ce95f1d5" }, "skills/statechart/scripts/test/statechart.test.mjs": { "upstream": "pstack", diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index 79d07b2..9a200df 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -184,7 +184,7 @@ }, "setup-mstack": { "source": "ef40d7cff8e0720dcb01e18f5ef44865ae1caaf0466045cecff8fb2cc9c5fc42", - "target": "ccc9c0c029bc15fcce99a3983cf0c9d461f480d704227fb814396958cb8ffa7a" + "target": "f78da4632da25656622e4d28485cea8a7eb1b4bdab234051d3d18ab64a2421dd" }, "sharpen": { "source": "563dfc460c605d311e650726288e2f1cbd61eab817db178d6e7e29643e07e7a2", @@ -275,7 +275,7 @@ }, "docs/guide/01-setup.md": { "source": "809266f58df5fd51c58a42fd17d26322e1e82924b283281b3094f0bec49dabac", - "target": "809266f58df5fd51c58a42fd17d26322e1e82924b283281b3094f0bec49dabac", + "target": "d73c4f0fd540cbd97d5715f09a70dd84459abee8460ac10264f4ae22cbd42b93", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." }, "docs/guide/03-understand.md": { @@ -285,12 +285,12 @@ }, "docs/guide/06-verify-and-ship.md": { "source": "8d7185d17cdff573a034e3f93449fcc8bb80180551ab2260d09bc84a8336afbe", - "target": "8d7185d17cdff573a034e3f93449fcc8bb80180551ab2260d09bc84a8336afbe", + "target": "4527eeeb9d5dab9d5673e12c12cfcab9207fb88364f896c1d5705654dd43087e", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." }, "docs/guide/07-overnight.md": { "source": "ff75e792447369c8baedc46f2400dca7110e36ffa750bd78be56a4c4d6c28e7d", - "target": "ff75e792447369c8baedc46f2400dca7110e36ffa750bd78be56a4c4d6c28e7d", + "target": "280b01f725912dc8efab95ba5a6eaba65a66eb3dee53cb94a028b88520fe5c44", "reason": "Offer a host scheduler as an alternative to a session loop for unattended runs." }, "docs/guide/README.md": { diff --git a/scripts/skill-baseline-lib.mjs b/scripts/skill-baseline-lib.mjs index 33a38b0..a08452f 100644 --- a/scripts/skill-baseline-lib.mjs +++ b/scripts/skill-baseline-lib.mjs @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; -const textExtensions = /\.(?:cjs|css|cts|hbs|html|js|json|lock|md|mjs|sh|ts|tsx|tsv|txt|yaml|yml)$/i; +const textExtensions = /\.(?:cfg|cjs|css|cts|hbs|html|js|json|lock|md|mmd|mjs|sh|tla|ts|tsx|tsv|txt|yaml|yml)$/i; const hashPattern = /^[a-f0-9]{64}$/; export function inside(root, path) { diff --git a/skills/setup-mstack/SKILL.md b/skills/setup-mstack/SKILL.md index 77dc992..17ad687 100644 --- a/skills/setup-mstack/SKILL.md +++ b/skills/setup-mstack/SKILL.md @@ -133,3 +133,13 @@ exists, offer once to run `/create-verification-skill`. Create it when the user has already requested that work or accepts the offer. Model configuration alone does not require launching the app or adopting a team policy. Mixed-Harness adoption is a separate project change described in the mstack guide. + +## Offer a formal verification skill (optional) + +Check whether the project already has a formal verification skill that runs a +model checker, proof, or other verifier against the code. If none exists, offer +once: "want a formal verification skill, so agents can prove a key property of +your code holds in every case in scope, not only the cases tests reach? I can +generate one with `/create-formal-verification-skill`." Create it when the user +accepts the offer. Prefer this for concurrency, retries, state machines, or +parsers where a bounded model can expose failures ordinary tests may miss. diff --git a/skills/statechart/scripts/statechart.mjs b/skills/statechart/scripts/statechart.mjs index 6e2ebfc..8117040 100644 --- a/skills/statechart/scripts/statechart.mjs +++ b/skills/statechart/scripts/statechart.mjs @@ -203,7 +203,7 @@ function parseArgs([command, ...rest]) { return { command, input, flags }; } -const rel = (file) => path.relative(process.cwd(), file) || "."; +const rel = (file) => path.relative(process.cwd(), file).split(path.sep).join("/") || "."; const cfgOf = (spec) => spec.replace(/\.tla$/, ".cfg"); function findTools() { From f0bd4549933840d22d0ab8d9fb3714bc28727c3f Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:29:36 +0800 Subject: [PATCH 6/6] fix(review): align guides and statechart portability --- profiles/upstream-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index 9a200df..5939cf0 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -285,12 +285,12 @@ }, "docs/guide/06-verify-and-ship.md": { "source": "8d7185d17cdff573a034e3f93449fcc8bb80180551ab2260d09bc84a8336afbe", - "target": "4527eeeb9d5dab9d5673e12c12cfcab9207fb88364f896c1d5705654dd43087e", + "target": "c933fc605724fc740608161f053c54063021ceae8cc0b0e91ad962f72e498921", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." }, "docs/guide/07-overnight.md": { "source": "ff75e792447369c8baedc46f2400dca7110e36ffa750bd78be56a4c4d6c28e7d", - "target": "280b01f725912dc8efab95ba5a6eaba65a66eb3dee53cb94a028b88520fe5c44", + "target": "2fe921479713551182e339787d2dead69dbc1bf625152a05c902375857ec7b72", "reason": "Offer a host scheduler as an alternative to a session loop for unattended runs." }, "docs/guide/README.md": {