raga-cli 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (487) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -2
  4. package/bundled-skills/AGENTS.md +5 -0
  5. package/bundled-skills/README.md +25 -0
  6. package/bundled-skills/agents/comment-sicko.json +9 -0
  7. package/bundled-skills/agents/comment-sicko.md +28 -0
  8. package/bundled-skills/agents/model-roles.json +41 -0
  9. package/bundled-skills/agents/poteto-agent.json +9 -0
  10. package/bundled-skills/agents/poteto-agent.md +4 -0
  11. package/bundled-skills/agents/worker.json +8 -0
  12. package/bundled-skills/agents/worker.md +1 -0
  13. package/bundled-skills/bundle.json +1514 -0
  14. package/bundled-skills/dependencies.json +40 -0
  15. package/bundled-skills/docs/guide/01-setup.md +3 -0
  16. package/bundled-skills/docs/guide/02-peak.md +135 -0
  17. package/bundled-skills/docs/guide/03-understand.md +79 -0
  18. package/bundled-skills/docs/guide/04-design.md +133 -0
  19. package/bundled-skills/docs/guide/05-build-and-clean.md +83 -0
  20. package/bundled-skills/docs/guide/06-verify-and-ship.md +166 -0
  21. package/bundled-skills/docs/guide/07-overnight.md +118 -0
  22. package/bundled-skills/docs/guide/08-principles.md +72 -0
  23. package/bundled-skills/docs/guide/09-make-it-yours.md +96 -0
  24. package/bundled-skills/docs/guide/10-recipes-and-pitfalls.md +180 -0
  25. package/bundled-skills/docs/guide/README.md +38 -0
  26. package/bundled-skills/docs/guide/images/design.jpg +0 -0
  27. package/bundled-skills/docs/guide/images/overnight.jpg +0 -0
  28. package/bundled-skills/docs/guide/images/recipes.jpg +0 -0
  29. package/bundled-skills/docs/guide/images/router.jpg +0 -0
  30. package/bundled-skills/docs/guide/images/understanding.jpg +0 -0
  31. package/bundled-skills/docs/guide/images/verification.jpg +0 -0
  32. package/bundled-skills/licenses/commander +22 -0
  33. package/bundled-skills/licenses/cursor-team-kit +21 -0
  34. package/bundled-skills/licenses/no-mistakes +21 -0
  35. package/bundled-skills/licenses/pstack +21 -0
  36. package/bundled-skills/skills/bend/SKILL.md +58 -0
  37. package/bundled-skills/skills/bend/bend_tool.py +144 -0
  38. package/bundled-skills/skills/peak/SKILL.md +184 -0
  39. package/bundled-skills/skills/peak/playbooks/authoring-a-skill.md +12 -0
  40. package/bundled-skills/skills/peak/playbooks/autonomous-run.md +13 -0
  41. package/bundled-skills/skills/peak/playbooks/autopilot-full.md +25 -0
  42. package/bundled-skills/skills/peak/playbooks/autopilot-stack.md +12 -0
  43. package/bundled-skills/skills/peak/playbooks/babysit.md +18 -0
  44. package/bundled-skills/skills/peak/playbooks/bug-fix.md +15 -0
  45. package/bundled-skills/skills/peak/playbooks/eval.md +25 -0
  46. package/bundled-skills/skills/peak/playbooks/feature.md +21 -0
  47. package/bundled-skills/skills/peak/playbooks/hillclimb.md +21 -0
  48. package/bundled-skills/skills/peak/playbooks/investigation.md +14 -0
  49. package/bundled-skills/skills/peak/playbooks/multi-phase-plan.md +155 -0
  50. package/bundled-skills/skills/peak/playbooks/opening-a-pr.md +34 -0
  51. package/bundled-skills/skills/peak/playbooks/orchestrate.md +112 -0
  52. package/bundled-skills/skills/peak/playbooks/pause-safely.md +10 -0
  53. package/bundled-skills/skills/peak/playbooks/perf-issue.md +25 -0
  54. package/bundled-skills/skills/peak/playbooks/prototype.md +14 -0
  55. package/bundled-skills/skills/peak/playbooks/refactoring.md +16 -0
  56. package/bundled-skills/skills/peak/playbooks/runtime-forensics.md +11 -0
  57. package/bundled-skills/skills/peak/playbooks/session-pickup.md +11 -0
  58. package/bundled-skills/skills/peak/playbooks/shipping.md +12 -0
  59. package/bundled-skills/skills/peak/playbooks/trace-forensics.md +14 -0
  60. package/bundled-skills/skills/peak/playbooks/visual-parity.md +11 -0
  61. package/bundled-skills/skills/peak/playbooks/worktree-cleanup.md +6 -0
  62. package/bundled-skills/skills/peak/references/architect/SKILL.md +81 -0
  63. package/bundled-skills/skills/peak/references/architect/references/design-red-flags.md +57 -0
  64. package/bundled-skills/skills/peak/references/architect/references/rationale-template.md +35 -0
  65. package/bundled-skills/skills/peak/references/architect/references/runner-prompt.md +20 -0
  66. package/bundled-skills/skills/peak/references/arena/SKILL.md +50 -0
  67. package/bundled-skills/skills/peak/references/automate-me/SKILL.md +106 -0
  68. package/bundled-skills/skills/peak/references/benchmark-checklist/SKILL.md +41 -0
  69. package/bundled-skills/skills/peak/references/blast-radius/SKILL.md +52 -0
  70. package/bundled-skills/skills/peak/references/bro/SKILL.md +9 -0
  71. package/bundled-skills/skills/peak/references/control-cli/SKILL.md +111 -0
  72. package/bundled-skills/skills/peak/references/control-ui/SKILL.md +111 -0
  73. package/bundled-skills/skills/peak/references/correct/SKILL.md +34 -0
  74. package/bundled-skills/skills/peak/references/create-formal-verification-skill/SKILL.md +43 -0
  75. package/bundled-skills/skills/peak/references/create-skill/SKILL.md +14 -0
  76. package/bundled-skills/skills/peak/references/create-verification-skill/SKILL.md +46 -0
  77. package/bundled-skills/skills/peak/references/create-verification-skill/references/feature-map-example/README.md +47 -0
  78. package/bundled-skills/skills/peak/references/create-verification-skill/references/feature-map-example/create-note.md +39 -0
  79. package/bundled-skills/skills/peak/references/create-verification-skill/references/feature-map-example/search.md +45 -0
  80. package/bundled-skills/skills/peak/references/deslop/SKILL.md +24 -0
  81. package/bundled-skills/skills/peak/references/figure-it-out/SKILL.md +54 -0
  82. package/bundled-skills/skills/peak/references/how/SKILL.md +38 -0
  83. package/bundled-skills/skills/peak/references/how/references/explainer-prompt.md +55 -0
  84. package/bundled-skills/skills/peak/references/how/references/explorer-prompt.md +35 -0
  85. package/bundled-skills/skills/peak/references/in-your-own-words/SKILL.md +9 -0
  86. package/bundled-skills/skills/peak/references/interrogate/SKILL.md +76 -0
  87. package/bundled-skills/skills/peak/references/interrogate/references/code-quality-review.md +47 -0
  88. package/bundled-skills/skills/peak/references/interrogate/references/lead-judgment.md +39 -0
  89. package/bundled-skills/skills/peak/references/interrogate/references/reviewer-prompt.md +62 -0
  90. package/bundled-skills/skills/peak/references/interrogate/references/rubric.md +77 -0
  91. package/bundled-skills/skills/peak/references/ketchup/SKILL.md +45 -0
  92. package/bundled-skills/skills/peak/references/maintain-formal-verification-skill/SKILL.md +60 -0
  93. package/bundled-skills/skills/peak/references/maintain-verification-skill/SKILL.md +41 -0
  94. package/bundled-skills/skills/peak/references/no-comments/SKILL.md +26 -0
  95. package/bundled-skills/skills/peak/references/no-mistakes/SKILL.md +20 -0
  96. package/bundled-skills/skills/peak/references/no-mistakes/upstream/SKILL.md +394 -0
  97. package/bundled-skills/skills/peak/references/peak-setup/SKILL.md +18 -0
  98. package/bundled-skills/skills/peak/references/poteto-help/SKILL.md +16 -0
  99. package/bundled-skills/skills/peak/references/poteto-help/references/prompting.md +51 -0
  100. package/bundled-skills/skills/peak/references/poteto-help/references/recipes.md +51 -0
  101. package/bundled-skills/skills/peak/references/principle-attack-the-premise/SKILL.md +23 -0
  102. package/bundled-skills/skills/peak/references/principle-boundary-discipline/SKILL.md +36 -0
  103. package/bundled-skills/skills/peak/references/principle-build-the-lever/SKILL.md +25 -0
  104. package/bundled-skills/skills/peak/references/principle-encode-lessons-in-structure/SKILL.md +13 -0
  105. package/bundled-skills/skills/peak/references/principle-exhaust-the-design-space/SKILL.md +11 -0
  106. package/bundled-skills/skills/peak/references/principle-experience-first/SKILL.md +21 -0
  107. package/bundled-skills/skills/peak/references/principle-explain-the-number/SKILL.md +19 -0
  108. package/bundled-skills/skills/peak/references/principle-fix-root-causes/SKILL.md +23 -0
  109. package/bundled-skills/skills/peak/references/principle-foundational-thinking/SKILL.md +23 -0
  110. package/bundled-skills/skills/peak/references/principle-guard-the-context-window/SKILL.md +14 -0
  111. package/bundled-skills/skills/peak/references/principle-laziness-protocol/SKILL.md +12 -0
  112. package/bundled-skills/skills/peak/references/principle-make-operations-idempotent/SKILL.md +24 -0
  113. package/bundled-skills/skills/peak/references/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +16 -0
  114. package/bundled-skills/skills/peak/references/principle-minimize-reader-load/SKILL.md +20 -0
  115. package/bundled-skills/skills/peak/references/principle-model-the-domain/SKILL.md +26 -0
  116. package/bundled-skills/skills/peak/references/principle-never-block-on-the-human/SKILL.md +17 -0
  117. package/bundled-skills/skills/peak/references/principle-outcome-oriented-execution/SKILL.md +23 -0
  118. package/bundled-skills/skills/peak/references/principle-prove-it-works/SKILL.md +24 -0
  119. package/bundled-skills/skills/peak/references/principle-redesign-from-first-principles/SKILL.md +18 -0
  120. package/bundled-skills/skills/peak/references/principle-separate-before-serializing-shared-state/SKILL.md +18 -0
  121. package/bundled-skills/skills/peak/references/principle-sequence-verifiable-units/SKILL.md +19 -0
  122. package/bundled-skills/skills/peak/references/principle-subtract-before-you-add/SKILL.md +20 -0
  123. package/bundled-skills/skills/peak/references/principle-test-behavior-not-implementation/SKILL.md +22 -0
  124. package/bundled-skills/skills/peak/references/principle-type-system-discipline/SKILL.md +15 -0
  125. package/bundled-skills/skills/peak/references/raga-runtime/SKILL.md +51 -0
  126. package/bundled-skills/skills/peak/references/raga-runtime/managed-tools.mjs +34 -0
  127. package/bundled-skills/skills/peak/references/raga-runtime/raga_runtime.py +55 -0
  128. package/bundled-skills/skills/peak/references/recall/SKILL.md +37 -0
  129. package/bundled-skills/skills/peak/references/reflect/SKILL.md +63 -0
  130. package/bundled-skills/skills/peak/references/reflect/references/divergent-reviewer.md +38 -0
  131. package/bundled-skills/skills/peak/references/reflect/references/judgment-reviewer.md +37 -0
  132. package/bundled-skills/skills/peak/references/reflect/references/synthesizer.md +56 -0
  133. package/bundled-skills/skills/peak/references/reflect/references/tooling-reviewer.md +48 -0
  134. package/bundled-skills/skills/peak/references/reviewer-triage.md +142 -0
  135. package/bundled-skills/skills/peak/references/sharpen/SKILL.md +18 -0
  136. package/bundled-skills/skills/peak/references/show-me-your-work/SKILL.md +84 -0
  137. package/bundled-skills/skills/peak/references/show-me-your-work/references/decision-log-template.tsv +1 -0
  138. package/bundled-skills/skills/peak/references/show-me-your-work/scripts/log.sh +42 -0
  139. package/bundled-skills/skills/peak/references/statechart/SKILL.md +38 -0
  140. package/bundled-skills/skills/peak/references/statechart/scripts/chart-to-tla.mjs +236 -0
  141. package/bundled-skills/skills/peak/references/statechart/scripts/compare-charts.mjs +104 -0
  142. package/bundled-skills/skills/peak/references/statechart/scripts/lib.mjs +725 -0
  143. package/bundled-skills/skills/peak/references/statechart/scripts/model/OutputFolder.cfg +6 -0
  144. package/bundled-skills/skills/peak/references/statechart/scripts/model/OutputFolder.map.mjs +106 -0
  145. package/bundled-skills/skills/peak/references/statechart/scripts/model/OutputFolder.tla +176 -0
  146. package/bundled-skills/skills/peak/references/statechart/scripts/statechart.mjs +749 -0
  147. package/bundled-skills/skills/peak/references/statechart/scripts/test/statechart.test.mjs +2790 -0
  148. package/bundled-skills/skills/peak/references/statechart/scripts/test/sync.mmd +19 -0
  149. package/bundled-skills/skills/peak/references/statechart/scripts/test/upload.mmd +16 -0
  150. package/bundled-skills/skills/peak/references/statechart/scripts/tlc-to-chart.mjs +1347 -0
  151. package/bundled-skills/skills/peak/references/swarm/SKILL.md +43 -0
  152. package/bundled-skills/skills/peak/references/tdd/SKILL.md +44 -0
  153. package/bundled-skills/skills/peak/references/teach/SKILL.md +23 -0
  154. package/bundled-skills/skills/peak/references/technical-writing/SKILL.md +108 -0
  155. package/bundled-skills/skills/peak/references/typescript-best-practices/SKILL.md +33 -0
  156. package/bundled-skills/skills/peak/references/typescript-best-practices/references/patterns.md +328 -0
  157. package/bundled-skills/skills/peak/references/unslop/SKILL.md +64 -0
  158. package/bundled-skills/skills/peak/references/verify-this/SKILL.md +76 -0
  159. package/bundled-skills/skills/peak/references/why/SKILL.md +100 -0
  160. package/bundled-skills/skills/peak/references/why/references/epistemics.md +71 -0
  161. package/bundled-skills/skills/peak/references/why/references/investigator-prompt.md +84 -0
  162. package/bundled-skills/skills/peak/references/why/references/source-playbook.md +17 -0
  163. package/bundled-skills/skills/peak/references/why/references/sources/code-archaeology.md +51 -0
  164. package/bundled-skills/skills/peak/references/why/references/sources/databricks.md +70 -0
  165. package/bundled-skills/skills/peak/references/why/references/sources/datadog.md +97 -0
  166. package/bundled-skills/skills/peak/references/why/references/sources/incident-postmortem.md +5 -0
  167. package/bundled-skills/skills/peak/references/why/references/sources/linear.md +48 -0
  168. package/bundled-skills/skills/peak/references/why/references/sources/notion.md +55 -0
  169. package/bundled-skills/skills/peak/references/why/references/sources/sentry.md +66 -0
  170. package/bundled-skills/skills/peak/references/why/references/sources/slack.md +17 -0
  171. package/bundled-skills/skills/peak/references/why/references/synthesizer-prompt.md +129 -0
  172. package/bundled-skills/skills/peak/scripts/check-plan.mjs +205 -0
  173. package/bundled-skills/skills/peak/scripts/check-plan.test.ts +96 -0
  174. package/bundled-skills/skills/peak/scripts/orch/orch.test.ts +761 -0
  175. package/bundled-skills/skills/peak/scripts/orch/orch.ts +588 -0
  176. package/bundled-skills/skills/peak/scripts/orch/store.ts +1676 -0
  177. package/bundled-skills/skills/peak/scripts/vendor/commander/LICENSE +22 -0
  178. package/bundled-skills/skills/peak/scripts/vendor/commander/esm.mjs +16 -0
  179. package/bundled-skills/skills/peak/scripts/vendor/commander/index.js +24 -0
  180. package/bundled-skills/skills/peak/scripts/vendor/commander/lib/argument.js +149 -0
  181. package/bundled-skills/skills/peak/scripts/vendor/commander/lib/command.js +2778 -0
  182. package/bundled-skills/skills/peak/scripts/vendor/commander/lib/error.js +39 -0
  183. package/bundled-skills/skills/peak/scripts/vendor/commander/lib/help.js +747 -0
  184. package/bundled-skills/skills/peak/scripts/vendor/commander/lib/option.js +379 -0
  185. package/bundled-skills/skills/peak/scripts/vendor/commander/lib/suggestSimilar.js +101 -0
  186. package/bundled-skills/skills/peak/scripts/vendor/commander/package.json +1 -0
  187. package/bundled-skills/skills/peak/scripts/vendor/commander/typings/esm.d.mts +3 -0
  188. package/bundled-skills/skills/peak/scripts/vendor/commander/typings/index.d.ts +1113 -0
  189. package/bundled-skills/skills/peak/scripts/watch-pr/cli.test.ts +224 -0
  190. package/bundled-skills/skills/peak/scripts/watch-pr/cli.ts +223 -0
  191. package/bundled-skills/skills/peak/scripts/watch-pr/fakes.test-helper.ts +129 -0
  192. package/bundled-skills/skills/peak/scripts/watch-pr/github.test.ts +436 -0
  193. package/bundled-skills/skills/peak/scripts/watch-pr/github.ts +718 -0
  194. package/bundled-skills/skills/peak/scripts/watch-pr/policy.test.ts +420 -0
  195. package/bundled-skills/skills/peak/scripts/watch-pr/policy.ts +836 -0
  196. package/bundled-skills/skills/peak/scripts/watch-pr/render.ts +169 -0
  197. package/bundled-skills/skills/peak/scripts/watch-pr/tsconfig.json +13 -0
  198. package/bundled-skills/skills/peak/scripts/watch-pr/types.compile.ts +93 -0
  199. package/bundled-skills/skills/peak/scripts/watch-pr/types.ts +413 -0
  200. package/bundled-skills/skills/peak/scripts/watch-pr/watch-pr +4 -0
  201. package/bundled-skills/skills/peak/scripts/worktree-audit.sh +4 -0
  202. package/bundled-skills/skills/raga-artifacts/SKILL.md +117 -0
  203. package/bundled-skills/skills/raga-artifacts/artifact.mjs +254 -0
  204. package/bundled-skills/skills/raga-artifacts/artifact_tool.py +55 -0
  205. package/bundled-skills/skills/raga-artifacts/diagrams.md +641 -0
  206. package/bundled-skills/skills/raga-artifacts/templates/doc.html +124 -0
  207. package/bundled-skills/skills/raga-artifacts/templates/slides.html +138 -0
  208. package/dist/bundle/chunks/anthropic-messages-BMDYDGHW.js +6 -0
  209. package/dist/bundle/chunks/anthropic.js +68 -0
  210. package/dist/bundle/chunks/azure-openai-responses-Q7EZTEHK.js +2 -0
  211. package/dist/bundle/chunks/bedrock-converse-stream.js +86 -0
  212. package/dist/bundle/chunks/chunk-3MNXTBQ3.js +2 -0
  213. package/dist/bundle/chunks/chunk-3V45WARH.js +7 -0
  214. package/dist/bundle/chunks/chunk-5AGIVGSV.js +6 -0
  215. package/dist/bundle/chunks/chunk-6QGV3IWD.js +6 -0
  216. package/dist/bundle/chunks/chunk-7CBJINYJ.js +20 -0
  217. package/dist/bundle/chunks/chunk-7DDKXS2Q.js +2 -0
  218. package/dist/bundle/chunks/chunk-7ZC4UXSL.js +2 -0
  219. package/dist/bundle/chunks/chunk-CF5GWZHW.js +31 -0
  220. package/dist/bundle/chunks/chunk-ENUUAC3O.js +131 -0
  221. package/dist/bundle/chunks/chunk-F7LFZTHT.js +1591 -0
  222. package/dist/bundle/chunks/chunk-G54UBZTF.js +44 -0
  223. package/dist/bundle/chunks/chunk-H76KZMOI.js +2 -0
  224. package/dist/bundle/chunks/chunk-IXDQLPKS.js +11 -0
  225. package/dist/bundle/chunks/chunk-JLV25LHE.js +2 -0
  226. package/dist/bundle/chunks/chunk-JQDZ4Z7L.js +2 -0
  227. package/dist/bundle/chunks/chunk-JXWJOIH4.js +2 -0
  228. package/dist/bundle/chunks/chunk-KNY3PXQI.js +2 -0
  229. package/dist/bundle/chunks/chunk-L2ZPNLYD.js +2 -0
  230. package/dist/bundle/chunks/chunk-LPXNMA2W.js +2 -0
  231. package/dist/bundle/chunks/chunk-LVVZS2NO.js +12 -0
  232. package/dist/bundle/chunks/chunk-LX24DVQ5.js +2 -0
  233. package/dist/bundle/chunks/chunk-NP6TH6UT.js +64 -0
  234. package/dist/bundle/chunks/chunk-OPPYO2IE.js +2 -0
  235. package/dist/bundle/chunks/chunk-P2QCWBFM.js +9 -0
  236. package/dist/bundle/chunks/chunk-P7FPUEYA.js +2 -0
  237. package/dist/bundle/chunks/chunk-PGXDEOVV.js +2 -0
  238. package/dist/bundle/chunks/chunk-PJACZCIR.js +2 -0
  239. package/dist/bundle/chunks/chunk-PMRZO46Y.js +2 -0
  240. package/dist/bundle/chunks/chunk-PRRNXB7S.js +2 -0
  241. package/dist/bundle/chunks/chunk-Q4U25BSY.js +2 -0
  242. package/dist/bundle/chunks/chunk-RDZVAGPR.js +2 -0
  243. package/dist/bundle/chunks/chunk-RFPFZTK2.js +7 -0
  244. package/dist/bundle/chunks/chunk-RP2WKNT7.js +4 -0
  245. package/dist/bundle/chunks/chunk-S5J3UB5O.js +75 -0
  246. package/dist/bundle/chunks/chunk-TEPMHNKQ.js +2 -0
  247. package/dist/bundle/chunks/chunk-TMTOQ7H7.js +2 -0
  248. package/dist/bundle/chunks/chunk-UKVNB6C4.js +2 -0
  249. package/dist/bundle/chunks/chunk-USJZMAIR.js +2 -0
  250. package/dist/bundle/chunks/chunk-UTB4DTYQ.js +2 -0
  251. package/dist/bundle/chunks/chunk-WJGLPWUE.js +16 -0
  252. package/dist/bundle/chunks/cli-QLDCDYOR.js +51 -0
  253. package/dist/bundle/chunks/cloudflare-workers-ai-system-one-S6CT2FXS.js +2 -0
  254. package/dist/bundle/chunks/codemode-worker.js +478 -0
  255. package/dist/bundle/chunks/easter-egg-3d-NPDBBPPF.js +2 -0
  256. package/dist/bundle/chunks/execute-WPTMS4XG.js +505 -0
  257. package/dist/bundle/chunks/github-copilot.js +2 -0
  258. package/dist/bundle/chunks/google-generative-ai-35NZKG6S.js +2 -0
  259. package/dist/bundle/chunks/google-vertex-P33AHYYX.js +2 -0
  260. package/dist/bundle/chunks/https-proxy-agent-LVOO5TPL.js +13 -0
  261. package/dist/bundle/chunks/image-resize-worker.js +2 -0
  262. package/dist/bundle/chunks/jiti-loader-GL4HJFAD.js +21 -0
  263. package/dist/bundle/chunks/jiti-static-loader-2RCDI6B4.js +2 -0
  264. package/dist/bundle/chunks/kimi-coding.js +2 -0
  265. package/dist/bundle/chunks/lib-4AVRLBHM.js +9 -0
  266. package/dist/bundle/chunks/llama-cpp-classify-U4EH6TAM.js +22 -0
  267. package/dist/bundle/chunks/meta.js +2 -0
  268. package/dist/bundle/chunks/mistral-conversations-J62DPS2P.js +5 -0
  269. package/dist/bundle/chunks/multipart-parser-FMGQ7342.js +3 -0
  270. package/dist/bundle/chunks/node-AUOGOBCN.js +2 -0
  271. package/dist/bundle/chunks/node-KCMANYC2.js +16 -0
  272. package/dist/bundle/chunks/openai-chatgpt.js +68 -0
  273. package/dist/bundle/chunks/openai-codex-responses-4HXATL2M.js +10 -0
  274. package/dist/bundle/chunks/openai-codex.js +68 -0
  275. package/dist/bundle/chunks/openai-completions-75YYY35N.js +7 -0
  276. package/dist/bundle/chunks/openai-decisions-HEBEVGA2.js +5 -0
  277. package/dist/bundle/chunks/openai-responses-UWNAKTBV.js +4 -0
  278. package/dist/bundle/chunks/openrouter-images-HVOPXKHS.js +2 -0
  279. package/dist/bundle/chunks/openrouter.js +68 -0
  280. package/dist/bundle/chunks/pi-messages-DG7CALOV.js +8 -0
  281. package/dist/bundle/chunks/radius.js +68 -0
  282. package/dist/bundle/chunks/runtime-N5MUH2WA.js +2 -0
  283. package/dist/bundle/chunks/src-QKGKDFSU.js +4 -0
  284. package/dist/bundle/chunks/typesafe-system-one-24HEGHNN.js +2 -0
  285. package/dist/bundle/chunks/virtual-modules-4I6Y6DPQ.js +2 -0
  286. package/dist/bundle/chunks/xai.js +2 -0
  287. package/dist/bundle/cli-runtime.js +3 -0
  288. package/dist/bundle/cli.js +5 -0
  289. package/dist/bundle/index.js +2 -0
  290. package/dist/bundle/raga-runtime.js +3 -0
  291. package/dist/bundle/raga.js +5 -0
  292. package/dist/bundle/rpc-entry.js +3 -0
  293. package/dist/core/export-html/template.css +1085 -0
  294. package/dist/core/export-html/template.html +56 -0
  295. package/dist/core/export-html/template.js +2044 -0
  296. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  297. package/dist/core/export-html/vendor/marked-cjk-friendly.min.js +6 -0
  298. package/dist/core/export-html/vendor/marked.min.js +79 -0
  299. package/dist/modes/interactive/assets/clankolas.png +0 -0
  300. package/dist/modes/interactive/theme/dark.json +78 -0
  301. package/dist/modes/interactive/theme/light.json +78 -0
  302. package/docs/cli-integration.md +106 -0
  303. package/docs/cli.md +343 -0
  304. package/docs/codemode.md +230 -0
  305. package/docs/compaction.md +489 -0
  306. package/docs/configuration.md +62 -0
  307. package/docs/containerization.md +183 -0
  308. package/docs/custom-provider.md +227 -0
  309. package/docs/docs.json +220 -0
  310. package/docs/environment-variables.md +101 -0
  311. package/docs/extensions.md +400 -0
  312. package/docs/how-pi-works.md +49 -0
  313. package/docs/images/doom-extension.png +0 -0
  314. package/docs/images/exy.png +0 -0
  315. package/docs/images/interactive-mode.png +0 -0
  316. package/docs/images/tree-view.png +0 -0
  317. package/docs/index.md +39 -0
  318. package/docs/json.md +226 -0
  319. package/docs/keybindings.md +195 -0
  320. package/docs/llama-cpp.md +121 -0
  321. package/docs/mcp.md +272 -0
  322. package/docs/message-types.md +279 -0
  323. package/docs/models-configuration.md +196 -0
  324. package/docs/models.md +225 -0
  325. package/docs/nvidia-rtx-llama-cpp.md +96 -0
  326. package/docs/packages.md +129 -0
  327. package/docs/prompt-templates.md +59 -0
  328. package/docs/providers.md +247 -0
  329. package/docs/quickstart.md +136 -0
  330. package/docs/rpc-commands.md +860 -0
  331. package/docs/rpc-extension-ui.md +200 -0
  332. package/docs/rpc.md +193 -0
  333. package/docs/sdk.md +149 -0
  334. package/docs/security.md +99 -0
  335. package/docs/session-format.md +293 -0
  336. package/docs/sessions.md +66 -0
  337. package/docs/settings.md +185 -0
  338. package/docs/shell-aliases.md +93 -0
  339. package/docs/skills.md +93 -0
  340. package/docs/slash-commands.md +61 -0
  341. package/docs/terminal-setup.md +233 -0
  342. package/docs/termux.md +117 -0
  343. package/docs/themes.md +138 -0
  344. package/docs/tmux.md +55 -0
  345. package/docs/tui.md +195 -0
  346. package/docs/usage.md +94 -0
  347. package/docs/virtual-models.md +114 -0
  348. package/docs/windows.md +81 -0
  349. package/examples/README.md +36 -0
  350. package/examples/extensions/README.md +214 -0
  351. package/examples/extensions/auto-commit-on-exit.ts +49 -0
  352. package/examples/extensions/bash-spawn-hook.ts +30 -0
  353. package/examples/extensions/bookmark.ts +50 -0
  354. package/examples/extensions/border-status-editor.ts +150 -0
  355. package/examples/extensions/built-in-tool-renderer.ts +225 -0
  356. package/examples/extensions/claude-rules.ts +86 -0
  357. package/examples/extensions/commands.ts +72 -0
  358. package/examples/extensions/confirm-destructive.ts +59 -0
  359. package/examples/extensions/custom-compaction.ts +117 -0
  360. package/examples/extensions/custom-footer.ts +64 -0
  361. package/examples/extensions/custom-header.ts +73 -0
  362. package/examples/extensions/custom-provider-anthropic/index.ts +617 -0
  363. package/examples/extensions/custom-provider-anthropic/package-lock.json +24 -0
  364. package/examples/extensions/custom-provider-anthropic/package.json +19 -0
  365. package/examples/extensions/custom-provider-gitlab-duo/index.ts +405 -0
  366. package/examples/extensions/custom-provider-gitlab-duo/package.json +16 -0
  367. package/examples/extensions/custom-provider-gitlab-duo/test.ts +82 -0
  368. package/examples/extensions/debug-provider.ts +90 -0
  369. package/examples/extensions/dirty-repo-guard.ts +56 -0
  370. package/examples/extensions/doom-overlay/README.md +46 -0
  371. package/examples/extensions/doom-overlay/doom/build/doom.js +21 -0
  372. package/examples/extensions/doom-overlay/doom/build/doom.wasm +0 -0
  373. package/examples/extensions/doom-overlay/doom/build.sh +152 -0
  374. package/examples/extensions/doom-overlay/doom/doomgeneric_pi.c +72 -0
  375. package/examples/extensions/doom-overlay/doom-component.ts +132 -0
  376. package/examples/extensions/doom-overlay/doom-engine.ts +173 -0
  377. package/examples/extensions/doom-overlay/doom-keys.ts +104 -0
  378. package/examples/extensions/doom-overlay/index.ts +74 -0
  379. package/examples/extensions/doom-overlay/wad-finder.ts +55 -0
  380. package/examples/extensions/dynamic-resources/SKILL.md +8 -0
  381. package/examples/extensions/dynamic-resources/dynamic.json +81 -0
  382. package/examples/extensions/dynamic-resources/dynamic.md +5 -0
  383. package/examples/extensions/dynamic-resources/index.ts +15 -0
  384. package/examples/extensions/dynamic-tools.ts +74 -0
  385. package/examples/extensions/entry-renderer.ts +41 -0
  386. package/examples/extensions/event-bus.ts +43 -0
  387. package/examples/extensions/file-trigger.ts +41 -0
  388. package/examples/extensions/git-checkpoint.ts +53 -0
  389. package/examples/extensions/git-merge-and-resolve.ts +115 -0
  390. package/examples/extensions/github-issue-autocomplete.ts +185 -0
  391. package/examples/extensions/gondolin/index.ts +531 -0
  392. package/examples/extensions/gondolin/package-lock.json +175 -0
  393. package/examples/extensions/gondolin/package.json +19 -0
  394. package/examples/extensions/handoff.ts +190 -0
  395. package/examples/extensions/hello.ts +26 -0
  396. package/examples/extensions/hidden-thinking-label.ts +53 -0
  397. package/examples/extensions/inline-bash.ts +94 -0
  398. package/examples/extensions/input-transform-streaming.ts +39 -0
  399. package/examples/extensions/input-transform.ts +43 -0
  400. package/examples/extensions/interactive-shell.ts +196 -0
  401. package/examples/extensions/jev-router.ts +113 -0
  402. package/examples/extensions/mac-system-theme.ts +47 -0
  403. package/examples/extensions/message-renderer.ts +59 -0
  404. package/examples/extensions/minimal-mode.ts +353 -0
  405. package/examples/extensions/modal-editor.ts +85 -0
  406. package/examples/extensions/model-status.ts +31 -0
  407. package/examples/extensions/notify.ts +57 -0
  408. package/examples/extensions/overlay-qa-tests.ts +1450 -0
  409. package/examples/extensions/overlay-test.ts +153 -0
  410. package/examples/extensions/permission-gate.ts +34 -0
  411. package/examples/extensions/pirate.ts +47 -0
  412. package/examples/extensions/plan-mode/README.md +66 -0
  413. package/examples/extensions/plan-mode/index.ts +394 -0
  414. package/examples/extensions/plan-mode/utils.ts +168 -0
  415. package/examples/extensions/preset.ts +436 -0
  416. package/examples/extensions/project-trust.ts +64 -0
  417. package/examples/extensions/prompt-customizer.ts +49 -0
  418. package/examples/extensions/protected-paths.ts +30 -0
  419. package/examples/extensions/provider-payload.ts +18 -0
  420. package/examples/extensions/qna.ts +118 -0
  421. package/examples/extensions/question.ts +286 -0
  422. package/examples/extensions/questionnaire.ts +448 -0
  423. package/examples/extensions/rainbow-editor.ts +88 -0
  424. package/examples/extensions/reload-runtime.ts +37 -0
  425. package/examples/extensions/rpc-demo.ts +118 -0
  426. package/examples/extensions/sandbox/index.ts +326 -0
  427. package/examples/extensions/sandbox/package-lock.json +92 -0
  428. package/examples/extensions/sandbox/package.json +19 -0
  429. package/examples/extensions/send-user-message.ts +97 -0
  430. package/examples/extensions/session-name.ts +27 -0
  431. package/examples/extensions/shutdown-command.ts +63 -0
  432. package/examples/extensions/snake.ts +343 -0
  433. package/examples/extensions/space-invaders.ts +560 -0
  434. package/examples/extensions/ssh.ts +220 -0
  435. package/examples/extensions/status-line.ts +32 -0
  436. package/examples/extensions/structured-output.ts +65 -0
  437. package/examples/extensions/subagent/README.md +177 -0
  438. package/examples/extensions/subagent/agents/planner.md +37 -0
  439. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  440. package/examples/extensions/subagent/agents/scout.md +50 -0
  441. package/examples/extensions/subagent/agents/worker.md +24 -0
  442. package/examples/extensions/subagent/agents.ts +157 -0
  443. package/examples/extensions/subagent/index.ts +1038 -0
  444. package/examples/extensions/subagent/prompts/implement-and-review.md +10 -0
  445. package/examples/extensions/subagent/prompts/implement.md +10 -0
  446. package/examples/extensions/subagent/prompts/scout-and-plan.md +9 -0
  447. package/examples/extensions/summarize.ts +199 -0
  448. package/examples/extensions/system-prompt-header.ts +17 -0
  449. package/examples/extensions/tic-tac-toe.ts +1008 -0
  450. package/examples/extensions/timed-confirm.ts +70 -0
  451. package/examples/extensions/titlebar-spinner.ts +58 -0
  452. package/examples/extensions/todo.ts +297 -0
  453. package/examples/extensions/tool-override.ts +144 -0
  454. package/examples/extensions/tools.ts +146 -0
  455. package/examples/extensions/trigger-compact.ts +50 -0
  456. package/examples/extensions/truncated-tool.ts +195 -0
  457. package/examples/extensions/widget-placement.ts +9 -0
  458. package/examples/extensions/with-deps/index.ts +32 -0
  459. package/examples/extensions/with-deps/package-lock.json +31 -0
  460. package/examples/extensions/with-deps/package.json +22 -0
  461. package/examples/extensions/working-indicator.ts +123 -0
  462. package/examples/extensions/working-message-test.ts +25 -0
  463. package/examples/rpc-client.ts +35 -0
  464. package/examples/rpc-extension-ui.ts +661 -0
  465. package/examples/sdk/01-minimal.ts +26 -0
  466. package/examples/sdk/02-custom-model.ts +49 -0
  467. package/examples/sdk/03-custom-prompt.ts +75 -0
  468. package/examples/sdk/04-skills.ts +55 -0
  469. package/examples/sdk/05-tools.ts +48 -0
  470. package/examples/sdk/06-extensions.ts +99 -0
  471. package/examples/sdk/07-context-files.ts +47 -0
  472. package/examples/sdk/08-prompt-templates.ts +51 -0
  473. package/examples/sdk/09-api-keys-and-oauth.ts +34 -0
  474. package/examples/sdk/10-settings.ts +53 -0
  475. package/examples/sdk/11-sessions.ts +52 -0
  476. package/examples/sdk/12-full-control.ts +74 -0
  477. package/examples/sdk/13-session-runtime.ts +67 -0
  478. package/examples/sdk/14-codemode-mcp.ts +61 -0
  479. package/examples/sdk/README.md +141 -0
  480. package/package.json +21 -5
  481. package/python/raga_rlm/__init__.py +352 -0
  482. package/python/raga_rlm/mlx_models.py +265 -0
  483. package/python/raga_rlm/repl.py +920 -0
  484. package/schemas/keybindings.schema.json +441 -0
  485. package/schemas/models.schema.json +1211 -0
  486. package/schemas/settings.schema.json +829 -0
  487. package/schemas/theme.schema.json +365 -0
@@ -0,0 +1,2790 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { delimiter, dirname, join } from "node:path";
14
+ import { test } from "node:test";
15
+ import { setTimeout as sleep } from "node:timers/promises";
16
+ import { fileURLToPath } from "node:url";
17
+ import {
18
+ checkTlcOutput,
19
+ findTlc,
20
+ parseDot,
21
+ parseMermaid,
22
+ parseTraces,
23
+ parseValue,
24
+ ruleSentences,
25
+ stateKey,
26
+ } from "../lib.mjs";
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const scripts = dirname(here);
30
+ const fixture = (name) => readFileSync(join(here, name), "utf8");
31
+ const edges = (r) => r.transitions.map((t) => `${t.from} -> ${t.to} : ${t.label}`).sort();
32
+ const node = (script, args, cwd, env = {}) =>
33
+ spawnSync(process.execPath, [join(scripts, script), ...args], {
34
+ cwd,
35
+ encoding: "utf8",
36
+ env: { ...process.env, ...env },
37
+ timeout: 120_000,
38
+ });
39
+
40
+ test("a transition on a composite leaves from every state inside it", () => {
41
+ const { regions } = parseMermaid(fixture("upload.mmd"));
42
+ const main = regions.Main;
43
+ assert.deepEqual([...main.states].sort(), [
44
+ "Nothing chosen",
45
+ "Upload failed",
46
+ "Uploaded",
47
+ "Uploading.Checking the file",
48
+ "Uploading.Sending the file",
49
+ ]);
50
+ assert.deepEqual([...main.initial], ["Nothing chosen"]);
51
+ assert.deepEqual(edges(main), [
52
+ "Nothing chosen -> Uploading.Sending the file : you choose a file",
53
+ "Upload failed -> Uploading.Sending the file : you retry",
54
+ "Uploading.Checking the file -> Nothing chosen : you cancel",
55
+ "Uploading.Checking the file -> Upload failed : the network drops",
56
+ "Uploading.Checking the file -> Uploaded : the check passes",
57
+ "Uploading.Sending the file -> Nothing chosen : you cancel",
58
+ "Uploading.Sending the file -> Upload failed : the network drops",
59
+ "Uploading.Sending the file -> Uploading.Checking the file : the last byte arrives",
60
+ ]);
61
+ });
62
+
63
+ test("the deepest source wins when a composite and a state inside it share a label", () => {
64
+ const { regions } = parseMermaid(`stateDiagram-v2
65
+ state Form {
66
+ [*] --> Typing
67
+ Typing --> Confirm : you press Escape
68
+ state Confirm
69
+ }
70
+ [*] --> Form
71
+ Form --> Closed : you press Escape
72
+ Confirm --> Closed : you press Escape`);
73
+ assert.deepEqual(edges(regions.Main), [
74
+ "Form.Confirm -> Closed : you press Escape",
75
+ "Form.Typing -> Form.Confirm : you press Escape",
76
+ ]);
77
+ });
78
+
79
+ test("a top-level composite split by -- makes one region per part", () => {
80
+ const { regions, nevers } = parseMermaid(fixture("sync.mmd"));
81
+ assert.deepEqual(Object.keys(regions), ["Connection", "Sync badge"]);
82
+ assert.deepEqual(edges(regions["Sync badge"]), [
83
+ "Synced -> Waiting to sync : the network drops",
84
+ "Synced -> Waiting to sync : you edit a note",
85
+ "Waiting to sync -> Synced : the server confirms",
86
+ ]);
87
+ assert.deepEqual(nevers, [
88
+ {
89
+ name: "NeverSyncedWhileOffline",
90
+ states: ["Offline", "Synced"],
91
+ sentence: 'You never see "Synced" while you are offline.',
92
+ },
93
+ ]);
94
+ });
95
+
96
+ test("a rule's sentence is the comment lines right above it, and a state key ignores field order", () => {
97
+ const sentences = ruleSentences(`---- MODULE M ----
98
+ \\* Not about a rule.
99
+
100
+ \\* You never see "Saved"
101
+ \\* while the note is a draft.
102
+ NoFalseSaved == TRUE
103
+ Bare == TRUE
104
+ ====`);
105
+ assert.deepEqual(
106
+ [...sentences],
107
+ [["NoFalseSaved", 'You never see "Saved" while the note is a draft.']],
108
+ );
109
+ assert.equal(stateKey({ b: 1, a: { d: 2, c: [3] } }), stateKey({ a: { c: [3], d: 2 }, b: 1 }));
110
+ });
111
+
112
+ test("the legend is not part of the machine", () => {
113
+ const { regions } = parseMermaid(`stateDiagram-v2
114
+ state "Legend" as legend {
115
+ state "Works today" as legendWorks
116
+ }
117
+ [*] --> A
118
+ A --> B : go`);
119
+ assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]);
120
+ });
121
+
122
+ test("the parser refuses charts it cannot read", () => {
123
+ assert.throws(() => parseMermaid("stateDiagram-v2\n state A {\n [*] --> B"), /unclosed/);
124
+ assert.throws(() => parseMermaid("stateDiagram-v2\n A ==> B"), /cannot read chart line/);
125
+ assert.throws(
126
+ () => parseMermaid("stateDiagram-v2\n state A {\n B --> C : go\n }\n [*] --> A"),
127
+ /no \[\*\] --> start/,
128
+ );
129
+ assert.throws(
130
+ () =>
131
+ parseMermaid(
132
+ "stateDiagram-v2\n state A {\n state B {\n [*] --> C\n --\n [*] --> D\n }\n [*] --> B\n }\n [*] --> A",
133
+ ),
134
+ /below the top level/,
135
+ );
136
+ assert.throws(
137
+ () => parseMermaid("stateDiagram-v2\n [*] --> A\n A --> [*] : you close the app"),
138
+ /final state/,
139
+ );
140
+ assert.throws(
141
+ () =>
142
+ parseMermaid(
143
+ 'stateDiagram-v2\n state "Something went wrong" as UploadError\n state "Something went wrong" as SyncError\n [*] --> UploadError\n UploadError --> SyncError : go',
144
+ ),
145
+ /two states have the caption "Something went wrong"/,
146
+ );
147
+ });
148
+
149
+ test("the parser reads class shorthand, repeated descriptions, and states first named inside a composite", () => {
150
+ const { regions } = parseMermaid(`stateDiagram-v2
151
+ classDef wrong fill:#fdd
152
+ N : Nothing chosen
153
+ N : press Upload to start
154
+ [*] --> N
155
+ N:::wrong --> Done : you finish
156
+ Done --> Moving:::wrong
157
+ state A {
158
+ [*] --> X
159
+ X --> Y : go
160
+ }
161
+ Moving --> A : start
162
+ Y --> Z : next
163
+ A --> N : cancel`);
164
+ assert.deepEqual([...regions.Main.states].sort(), [
165
+ "A.X",
166
+ "A.Y",
167
+ "Done",
168
+ "Moving",
169
+ "Nothing chosen",
170
+ "Z",
171
+ ]);
172
+ assert.deepEqual(edges(regions.Main), [
173
+ "A.X -> A.Y : go",
174
+ "A.X -> Nothing chosen : cancel",
175
+ "A.Y -> Nothing chosen : cancel",
176
+ "A.Y -> Z : next",
177
+ "Done -> Moving : ",
178
+ "Moving -> A.X : start",
179
+ "Nothing chosen -> Done : you finish",
180
+ ]);
181
+ });
182
+
183
+ test("TLC values, states, graphs, and traces parse", () => {
184
+ assert.deepEqual(parseValue('[a |-> <<1, "x">>, b |-> {TRUE}]'), { a: [1, "x"], b: [true] });
185
+ assert.deepEqual(parseValue('("p" :> 1 @@ "q" :> 2)'), { p: 1, q: 2 });
186
+ const graph = parseDot(`strict digraph DiskGraph {
187
+ 1 [label="/\\\\ x = 0",style = filled]
188
+ 2 [label="/\\\\ x = 1"]
189
+ 1 -> 2 [label="Inc",color="black",fontcolor="black"];
190
+ 1 -> 2 [label="Inc",color="black",fontcolor="black"];
191
+ 2 -> 2 [label="Stay"];
192
+ }`);
193
+ assert.equal(graph.nodes.get("1").init, true);
194
+ assert.deepEqual(graph.nodes.get("2").vars, { x: 1 });
195
+ assert.deepEqual(graph.edges, [{ from: "1", to: "2", action: "Inc" }]);
196
+ assert.deepEqual(graph.loops, [{ from: "2", to: "2", action: "Stay" }]);
197
+ assert.throws(
198
+ () =>
199
+ parseDot(
200
+ 'strict digraph DiskGraph {\n1 [label="/\\\\ x = 0",style = filled]\n2 [label="/\\\\ x = 1"]\n1 -> 2;\n}',
201
+ ),
202
+ /no action names/,
203
+ );
204
+ const [trace] = parseTraces(`Error: Invariant Small is violated.
205
+ Error: The behavior up to this point is:
206
+ State 1: <Initial predicate>
207
+ x = 0
208
+
209
+ State 2: <Inc line 9, col 8 to line 9, col 20 of module M>
210
+ x = 1
211
+ `);
212
+ assert.equal(trace.invariant, "Small");
213
+ assert.deepEqual(trace.steps, [
214
+ { action: null, vars: { x: 0 } },
215
+ { action: "Inc", vars: { x: 1 } },
216
+ ]);
217
+ });
218
+
219
+ const done = "Model checking completed. No error has been found.\n";
220
+ const evaluationError = `Error: Invariant NoLost is violated.
221
+ Error: The behavior up to this point is:
222
+ State 1: <Initial predicate>
223
+ s = "Idle"
224
+
225
+ State 2: <Lose line 4, col 9 to line 4, col 33 of module F>
226
+ s = "Lost"
227
+
228
+ Error: TLC threw an unexpected exception.
229
+ This was probably caused by an error in the spec or model.
230
+
231
+ Error: The behavior up to this point is:
232
+ State 1: <Initial predicate>
233
+ s = "Idle"
234
+
235
+ State 2: <Save line 5, col 9 to line 5, col 35 of module F>
236
+ s = "Saving"
237
+
238
+ 3 states generated, 3 distinct states found, 0 states left on queue.
239
+ `;
240
+
241
+ test("a rule broken by a start state is a counterexample of one state", () => {
242
+ const text = `Error: Invariant NeverSyncedWhileOffline is violated by the initial state:
243
+ /\\ badge = "Synced"
244
+ /\\ conn = "Offline"
245
+
246
+ ${done}`;
247
+ assert.doesNotThrow(() => checkTlcOutput(text, "tlc.out"));
248
+ assert.deepEqual(parseTraces(text), [
249
+ {
250
+ invariant: "NeverSyncedWhileOffline",
251
+ steps: [{ action: null, vars: { badge: "Synced", conn: "Offline" } }],
252
+ },
253
+ ]);
254
+ });
255
+
256
+ test("TLC output from a run that failed or stopped early is refused", () => {
257
+ assert.doesNotThrow(() =>
258
+ checkTlcOutput(
259
+ `Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: <Initial predicate>\nx = 0\n\n${done}`,
260
+ "ok.out",
261
+ ),
262
+ );
263
+ assert.throws(
264
+ () =>
265
+ checkTlcOutput(
266
+ "Error: The invariant Nope specified in the configuration file\nis not defined in the specification.\n",
267
+ "cfg.out",
268
+ ),
269
+ /cfg\.out: TLC stopped on an error that is not a broken rule/,
270
+ );
271
+ assert.throws(
272
+ () => checkTlcOutput("Error: Parsing or semantic analysis failed.\n", "parse.out"),
273
+ /not a broken rule/,
274
+ );
275
+ assert.throws(
276
+ () => checkTlcOutput(evaluationError, "eval.out"),
277
+ /TLC threw an unexpected exception/,
278
+ );
279
+ assert.throws(
280
+ () =>
281
+ checkTlcOutput(
282
+ "Error: Invariant Small is violated.\nError: The behavior up to this point is:\nState 1: <Initial predicate>\nx = 0\n",
283
+ "stopped.out",
284
+ ),
285
+ /did not finish/,
286
+ );
287
+ assert.deepEqual(
288
+ parseTraces(evaluationError).map((t) => t.invariant),
289
+ ["NoLost", null],
290
+ );
291
+ });
292
+
293
+ test("a step that changes nothing keeps a state unsettled, and a quote in a reader label survives the chart", () => {
294
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
295
+ writeFileSync(
296
+ join(dir, "save.dot"),
297
+ `strict digraph DiskGraph {
298
+ 1 [label="/\\\\ s = \\"Idle\\"",style = filled]
299
+ 2 [label="/\\\\ s = \\"Saving\\""]
300
+ 1 -> 2 [label="Tap"];
301
+ 2 -> 2 [label="Retry"];
302
+ }`,
303
+ );
304
+ writeFileSync(
305
+ join(dir, "save.map.mjs"),
306
+ `export const readerActions = ["Tap"];
307
+ export const state = (v, facts) => (v.s === "Idle" ? 'Badge says "Synced"' : facts.settled ? "Saved" : "Saving");
308
+ export const step = (action) => ({ Tap: "you tap Save" })[action] ?? "";
309
+ `,
310
+ );
311
+ const r = node(
312
+ "tlc-to-chart.mjs",
313
+ ["--dot", "save.dot", "--map", "save.map.mjs", "--highlight", "0", "--out", "chart.mmd"],
314
+ dir,
315
+ );
316
+ assert.equal(r.status, 0, r.stderr);
317
+ assert.deepEqual(edges(parseMermaid(readFileSync(join(dir, "chart.mmd"), "utf8")).regions.Main), [
318
+ 'Badge says "Synced" -> Saving : you tap Save',
319
+ ]);
320
+ });
321
+
322
+ test("chart-to-tla writes one action per label and one invariant per never line", () => {
323
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
324
+ writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd"));
325
+ const r = node("chart-to-tla.mjs", ["sync.mmd", "--module", "Sync"], dir);
326
+ assert.equal(r.status, 0, r.stderr);
327
+ const spec = readFileSync(join(dir, "Sync.tla"), "utf8");
328
+ assert.match(spec, /^VARIABLES connection, syncBadge$/m);
329
+ assert.match(
330
+ spec,
331
+ /^TheNetworkDrops ==\n {4}\/\\ \(connection \\in \{"Online"\} \\\/ syncBadge \\in \{"Synced"\}\)$/m,
332
+ );
333
+ assert.match(
334
+ spec,
335
+ /^NeverSyncedWhileOffline == ~\(connection = "Offline" \/\\ syncBadge = "Synced"\)$/m,
336
+ );
337
+ assert.equal(
338
+ readFileSync(join(dir, "Sync.cfg"), "utf8"),
339
+ "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n",
340
+ );
341
+ assert.match(r.stderr, /rules TypeOK NeverSyncedWhileOffline/);
342
+ });
343
+
344
+ test("chart-to-tla refuses a never line that names no state", () => {
345
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
346
+ writeFileSync(
347
+ join(dir, "c.mmd"),
348
+ "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n",
349
+ );
350
+ const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir);
351
+ assert.notEqual(r.status, 0);
352
+ assert.match(r.stderr, /names "C", which is not a state/);
353
+ });
354
+
355
+ test("a never line names a state exactly, or every state inside a composite", () => {
356
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
357
+ writeFileSync(
358
+ join(dir, "c.mmd"),
359
+ `stateDiagram-v2
360
+ state "Saving" as S
361
+ state "Saving..." as S2
362
+ state Busy {
363
+ [*] --> Checking
364
+ Checking --> Sending : the check passes
365
+ }
366
+ [*] --> S
367
+ S --> S2 : you wait
368
+ S2 --> Busy : you send
369
+ Busy --> S : you cancel
370
+ %% never NoSaving: Saving -- You never see Saving.
371
+ %% never NoBusy: Busy -- You are never busy.
372
+ `,
373
+ );
374
+ const r = node("chart-to-tla.mjs", ["c.mmd", "--module", "C"], dir);
375
+ assert.equal(r.status, 0, r.stderr);
376
+ const spec = readFileSync(join(dir, "C.tla"), "utf8");
377
+ assert.match(spec, /^NoSaving == ~\(main = "Saving"\)$/m);
378
+ assert.match(spec, /^NoBusy == ~\(main \\in \{"Busy\.Checking", "Busy\.Sending"\}\)$/m);
379
+ assert.match(
380
+ readFileSync(join(dir, "C.map.mjs"), "utf8"),
381
+ /^export const readerActions = \[\];$/m,
382
+ );
383
+ });
384
+
385
+ const door = `stateDiagram-v2
386
+ state Door {
387
+ [*] --> Locked
388
+ Locked --> Unlocked : tap unlock
389
+ Unlocked --> Locked : tap lock
390
+ --
391
+ [*] --> Closed
392
+ Closed --> Open : open the door
393
+ Open --> Closed : close the door
394
+ }
395
+ [*] --> Door`;
396
+ const flatDoor = (extra = []) =>
397
+ [
398
+ "stateDiagram-v2",
399
+ ' state "Locked | Closed" as L_C',
400
+ ' state "Locked | Open" as L_O',
401
+ ' state "Unlocked | Closed" as U_C',
402
+ ' state "Unlocked | Open" as U_O',
403
+ " [*] --> L_C",
404
+ " L_C --> U_C : tap unlock",
405
+ " U_C --> L_C : tap lock",
406
+ " L_O --> U_O : tap unlock",
407
+ " L_C --> L_O : open the door",
408
+ " L_O --> L_C : close the door",
409
+ " U_C --> U_O : open the door",
410
+ " U_O --> U_C : close the door",
411
+ ...extra,
412
+ ].join("\n");
413
+
414
+ test("compare reads parallel regions as one machine, so a guard across regions shows", () => {
415
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
416
+ writeFileSync(join(dir, "agreed.mmd"), door);
417
+ writeFileSync(join(dir, "guarded.mmd"), flatDoor());
418
+ writeFileSync(join(dir, "free.mmd"), flatDoor([" U_O --> L_O : tap lock"]));
419
+ const differ = node("compare-charts.mjs", ["agreed.mmd", "guarded.mmd"], dir);
420
+ assert.equal(differ.status, 1);
421
+ assert.match(
422
+ differ.stdout,
423
+ /transitions only in EXPECTED[^\n]*:\n {2}Unlocked \| Open --> Locked \| Open : tap lock\n1 difference\(s\)/,
424
+ );
425
+ const same = node("compare-charts.mjs", ["agreed.mmd", "free.mmd"], dir);
426
+ assert.equal(same.stdout.trim(), "same", same.stdout);
427
+ });
428
+
429
+ const lamp = `stateDiagram-v2
430
+ state "Lamp" as lamp {
431
+ state "Power" as power {
432
+ [*] --> Off
433
+ Off --> On : flip the switch
434
+ On --> Off : flip the switch
435
+ }
436
+ --
437
+ state "Bulb" as bulb {
438
+ [*] --> Dark
439
+ Dark --> Lit : warm up [when the<br/>power is on]
440
+ %% when On
441
+ Lit --> Dark : cool down [when the power is off]
442
+ %% when not On
443
+ }
444
+ }
445
+ %% never LitWhileOff: Off & Lit -- The bulb is never lit while the power is off.
446
+ `;
447
+
448
+ test("a %% when line guards a step on the other region's states, and [words] are only its text", () => {
449
+ const { regions } = parseMermaid(lamp);
450
+ assert.deepEqual(regions.Bulb.transitions, [
451
+ { from: "Dark", to: "Lit", label: "warm up", guard: { not: false, states: { Power: ["On"] } } },
452
+ {
453
+ from: "Lit",
454
+ to: "Dark",
455
+ label: "cool down",
456
+ guard: { not: true, states: { Power: ["On"] } },
457
+ },
458
+ ]);
459
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
460
+ writeFileSync(join(dir, "lamp.mmd"), lamp);
461
+ writeFileSync(
462
+ join(dir, "flat.mmd"),
463
+ [
464
+ "stateDiagram-v2",
465
+ " [*] --> OD",
466
+ ' state "Off | Dark" as OD',
467
+ ' state "On | Dark" as ND',
468
+ ' state "On | Lit" as NL',
469
+ ' state "Off | Lit" as OL',
470
+ " OD --> ND : flip the switch",
471
+ " ND --> OD : flip the switch",
472
+ " ND --> NL : warm up",
473
+ " NL --> OL : flip the switch",
474
+ " OL --> NL : flip the switch",
475
+ " OL --> OD : cool down",
476
+ ].join("\n"),
477
+ );
478
+ const same = node("compare-charts.mjs", ["lamp.mmd", "flat.mmd"], dir);
479
+ assert.equal(same.stdout, "same\n", same.stderr);
480
+ const tla = node("chart-to-tla.mjs", ["lamp.mmd", "--module", "Lamp"], dir);
481
+ assert.equal(tla.status, 0, tla.stderr);
482
+ const spec = readFileSync(join(dir, "Lamp.tla"), "utf8");
483
+ assert.match(spec, /\(bulb = "Dark" \/\\ power = "On" \/\\ bulb' = "Lit"\)/);
484
+ assert.match(spec, /\(bulb = "Lit" \/\\ ~\(power = "On"\) \/\\ bulb' = "Dark"\)/);
485
+
486
+ for (const [from, to, why] of [
487
+ [
488
+ " %% when On\n",
489
+ " state Glow\n %% when On\n",
490
+ /must come right after a transition/,
491
+ ],
492
+ [
493
+ "warm up [when the<br/>power is on]",
494
+ "warm up",
495
+ /must end its label with the guard's words in \[ \]/,
496
+ ],
497
+ [" %% when On\n", " %% when Nope\n", /names Nope, which is not a state id/],
498
+ [" %% when On\n", " %% when Lit\n", /names Lit, which is in the step's own region/],
499
+ ])
500
+ assert.throws(() => parseMermaid(lamp.replace(from, to)), why);
501
+ assert.throws(
502
+ () =>
503
+ parseMermaid(
504
+ "stateDiagram-v2\n [*] --> A\n A --> B : go [when C]\n %% when C\n B --> C : on\n",
505
+ ),
506
+ /the chart has none/,
507
+ );
508
+ assert.deepEqual(
509
+ edges(parseMermaid("stateDiagram-v2\n [*] --> A\n A --> B : go [user away]\n").regions.Main),
510
+ ["A -> B : go"],
511
+ );
512
+ });
513
+
514
+ const switchLamp = `stateDiagram-v2
515
+ state "Lamp" as lamp {
516
+ state "Power" as power {
517
+ [*] --> Off
518
+ Off --> On : flip the switch
519
+ On --> Off : flip the switch
520
+ }
521
+ --
522
+ state "Bulb" as bulb {
523
+ [*] --> Dark
524
+ Dark --> Lit : flip the switch [when the power is off]
525
+ %% when Off
526
+ Lit --> Dark : flip the switch
527
+ }
528
+ }
529
+ `;
530
+
531
+ test("a label with a guarded and an unguarded move in one region writes both moves", () => {
532
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
533
+ writeFileSync(join(dir, "switch.mmd"), switchLamp);
534
+ const r = node("chart-to-tla.mjs", ["switch.mmd", "--module", "Switch"], dir);
535
+ assert.equal(r.status, 0, r.stderr);
536
+ const spec = readFileSync(join(dir, "Switch.tla"), "utf8");
537
+ assert.equal(
538
+ spec.slice(spec.indexOf("\\* flip the switch"), spec.indexOf("\nNext ==")),
539
+ `\\* flip the switch
540
+ FlipTheSwitch ==
541
+ /\\ (power \\in {"Off", "On"} \\/ (bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit"))
542
+ /\\ \\/ (power = "Off" /\\ power' = "On")
543
+ \\/ (power = "On" /\\ power' = "Off")
544
+ \\/ (power \\notin {"Off", "On"} /\\ UNCHANGED power)
545
+ /\\ \\/ (bulb = "Dark" /\\ power = "Off" /\\ bulb' = "Lit")
546
+ \\/ (bulb = "Lit" /\\ bulb' = "Dark")
547
+ \\/ (~((bulb = "Dark" /\\ power = "Off") \\/ (bulb = "Lit")) /\\ UNCHANGED bulb)
548
+ `,
549
+ );
550
+ });
551
+
552
+ test("compare reads a one-region chart as the machine its steps reach, without steps that change nothing", () => {
553
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
554
+ const drawn =
555
+ "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy : start\n Busy --> Idle : finish\n";
556
+ writeFileSync(join(dir, "drawn.mmd"), drawn);
557
+ writeFileSync(join(dir, "loop.mmd"), `${drawn} Idle --> Idle : refresh\n`);
558
+ writeFileSync(join(dir, "orphan.mmd"), `${drawn} Lost --> Idle : come back\n`);
559
+ const same = node("compare-charts.mjs", ["loop.mmd", "drawn.mmd"], dir);
560
+ assert.equal(same.status, 0);
561
+ assert.equal(same.stdout, "same\n");
562
+ const orphan = node("compare-charts.mjs", ["orphan.mmd", "drawn.mmd"], dir);
563
+ assert.equal(orphan.status, 1);
564
+ assert.equal(
565
+ orphan.stdout,
566
+ "states the agreed chart's own steps never reach:\n Lost\n1 difference(s)\n",
567
+ );
568
+ });
569
+
570
+ test("a -- outside a composite is refused, not read as one region", () => {
571
+ const split =
572
+ "stateDiagram-v2\n [*] --> Online\n Online --> Offline : the network drops\n --\n [*] --> Synced\n Synced --> Pending : you edit a note\n";
573
+ assert.throws(() => parseMermaid(split), /-- outside a composite/);
574
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
575
+ writeFileSync(join(dir, "split.mmd"), split);
576
+ writeFileSync(join(dir, "door.mmd"), door);
577
+ const tla = node("chart-to-tla.mjs", ["split.mmd", "--module", "Split"], dir);
578
+ assert.notEqual(tla.status, 0);
579
+ assert.match(tla.stderr, /-- outside a composite/);
580
+ for (const pair of [
581
+ ["split.mmd", "split.mmd"],
582
+ ["door.mmd", "split.mmd"],
583
+ ["split.mmd", "door.mmd"],
584
+ ]) {
585
+ const r = node("compare-charts.mjs", pair, dir);
586
+ assert.equal(r.status, 2, pair.join(" "));
587
+ assert.match(r.stderr, /^split\.mmd: the chart has -- outside a composite/);
588
+ }
589
+ });
590
+
591
+ test("compare refuses a chart it cannot read, or an ACTUAL chart that is not drawn for comparison", () => {
592
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
593
+ writeFileSync(join(dir, "agreed.mmd"), door);
594
+ for (const pair of [
595
+ ["agreed.mmd", "missing.mmd"],
596
+ ["missing.mmd", "agreed.mmd"],
597
+ ]) {
598
+ const r = node("compare-charts.mjs", pair, dir);
599
+ assert.equal(r.status, 2, pair.join(" "));
600
+ assert.match(r.stderr, /^missing\.mmd: ENOENT/);
601
+ }
602
+ for (const [extra, why] of [
603
+ [" U_O --> L_O : 1. tap lock", /--highlight 0/],
604
+ [
605
+ " %% second model (against): the spec",
606
+ "actual.mmd: it has a second model; draw ACTUAL without --fixed-dot or --against-dot\n",
607
+ ],
608
+ [
609
+ ' state "Locked" as g1 {\n state "Closed" as L_C2\n }',
610
+ "actual.mmd: it has boxes; draw ACTUAL with --flat\n",
611
+ ],
612
+ ]) {
613
+ writeFileSync(join(dir, "actual.mmd"), flatDoor([extra]));
614
+ const r = node("compare-charts.mjs", ["agreed.mmd", "actual.mmd"], dir);
615
+ assert.equal(r.status, 2, extra);
616
+ if (typeof why === "string") assert.equal(r.stderr, why);
617
+ else assert.match(r.stderr, why);
618
+ }
619
+ });
620
+
621
+ test("an edge lists every step, writes shared leading words once, and the chart renders at its natural width", () => {
622
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
623
+ const nodes = [
624
+ ["Home", 1],
625
+ ["Home", 2],
626
+ ["Home", 3],
627
+ ["Away", 1],
628
+ ["Away", 2],
629
+ ["Done", 1],
630
+ ].map(
631
+ ([s, n], i) =>
632
+ `${i + 1} [label="/\\\\ s = \\"${s}\\"\\n/\\\\ n = ${n}"${i ? "" : ",style = filled"}]`,
633
+ );
634
+ const steps = [
635
+ "1 -> 4 TapPay",
636
+ "2 -> 5 TapBack",
637
+ "3 -> 4 TapHome",
638
+ "4 -> 1 OpenThread",
639
+ "5 -> 2 OpenApp",
640
+ "4 -> 6 Finish",
641
+ ];
642
+ writeFileSync(
643
+ join(dir, "nav.dot"),
644
+ `strict digraph DiskGraph {\n${nodes.join("\n")}\n${steps.map((e) => e.replace(/ (\w+)$/, ' [label="$1"];')).join("\n")}\n}\n`,
645
+ );
646
+ writeFileSync(
647
+ join(dir, "nav.map.mjs"),
648
+ `export const state = (v) => v.s;
649
+ 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];
650
+ `,
651
+ );
652
+ writeFileSync(
653
+ join(dir, "agreed.mmd"),
654
+ `stateDiagram-v2
655
+ [*] --> Home
656
+ Home --> Away : you tap Pay
657
+ Home --> Away : you tap Back
658
+ Home --> Away : you tap Home
659
+ Away --> Home : you open the thread
660
+ Away --> Home : you open the phone app
661
+ Away --> Done
662
+ `,
663
+ );
664
+ const draw = (...extra) => {
665
+ const r = node(
666
+ "tlc-to-chart.mjs",
667
+ [
668
+ "--dot",
669
+ "nav.dot",
670
+ "--map",
671
+ "nav.map.mjs",
672
+ "--highlight",
673
+ "0",
674
+ "--out",
675
+ "chart.mmd",
676
+ ...extra,
677
+ ],
678
+ dir,
679
+ );
680
+ assert.equal(r.status, 0, r.stderr);
681
+ return readFileSync(join(dir, "chart.mmd"), "utf8");
682
+ };
683
+ const chart = draw();
684
+ assert.ok(
685
+ chart.startsWith(
686
+ "---\nconfig:\n markdownAutoWrap: false\n state:\n useMaxWidth: false\n---\nstateDiagram-v2\n",
687
+ ),
688
+ chart,
689
+ );
690
+ const shortened = [
691
+ "Away -> Done : ",
692
+ "Away -> Home : you open the thread or the phone app",
693
+ "Home -> Away : you tap Pay, Back, or Home",
694
+ ];
695
+ assert.deepEqual(edges(parseMermaid(chart).regions.Main), shortened);
696
+ const crlf = chart.replace("---\n", "--- \n").replaceAll("\n", "\r\n");
697
+ assert.deepEqual(edges(parseMermaid(crlf).regions.Main), shortened);
698
+ const refused = node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir);
699
+ assert.equal(refused.status, 2);
700
+ assert.match(refused.stderr, /draw ACTUAL with --full-labels/);
701
+ const full = draw("--full-labels");
702
+ assert.match(full, /^stateDiagram-v2\n {2}%% labels in full\n/m);
703
+ assert.deepEqual(edges(parseMermaid(full).regions.Main), [
704
+ "Away -> Done : ",
705
+ "Away -> Home : you open the thread or you open the phone app",
706
+ "Home -> Away : you tap Pay or you tap Back or you tap Home",
707
+ ]);
708
+ assert.equal(node("compare-charts.mjs", ["agreed.mmd", "chart.mmd"], dir).stdout, "same\n");
709
+ });
710
+
711
+ const NO_TLC = `Cannot find TLC. Download tla2tools.jar from a TLA+ release:
712
+ https://github.com/tlaplus/tlaplus/releases/download/v1.8.0/tla2tools.jar
713
+ Then set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).
714
+ `;
715
+ const FROM_SPEC_USAGE =
716
+ "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]";
717
+ const FROM_CHART_USAGE =
718
+ "node statechart.mjs from-chart CHART.mmd [--module NAME] [--expect-broken RULE]... [--title TEXT] [--out DIR] [--png]";
719
+ const noTlc = { TLA2TOOLS: "", JAVA: "" };
720
+
721
+ test("statechart.mjs writes the spec next to the chart, keeps it on the next run, and stops without TLC", () => {
722
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
723
+ writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd"));
724
+ const first = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc);
725
+ assert.equal(first.status, 2);
726
+ assert.equal(
727
+ first.stdout,
728
+ "Wrote NotesApp.tla, NotesApp.cfg, and NotesApp.map.mjs from notes-app.mmd.\n",
729
+ );
730
+ assert.equal(first.stderr, NO_TLC);
731
+ assert.equal(
732
+ readFileSync(join(dir, "NotesApp.cfg"), "utf8"),
733
+ "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NeverSyncedWhileOffline\n",
734
+ );
735
+ const again = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, noTlc);
736
+ assert.equal(
737
+ again.stdout,
738
+ "Kept NotesApp.tla. Delete it to write the spec again from notes-app.mmd.\n",
739
+ );
740
+ const spec = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, noTlc);
741
+ assert.equal(spec.status, 2);
742
+ assert.equal(spec.stderr, NO_TLC);
743
+ const missingJar = node("statechart.mjs", ["from-spec", "NotesApp.tla"], dir, {
744
+ TLA2TOOLS: "/nonexistent/tla2tools.jar",
745
+ });
746
+ assert.equal(missingJar.status, 2);
747
+ assert.equal(
748
+ missingJar.stderr,
749
+ "TLA2TOOLS is /nonexistent/tla2tools.jar, which does not exist.\n",
750
+ );
751
+ });
752
+
753
+ test("statechart.mjs writes no output folder when it refuses the input", () => {
754
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
755
+ writeFileSync(join(dir, "tla2tools.jar"), "");
756
+ writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n");
757
+ writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT Paid\n");
758
+ writeFileSync(join(dir, "cart.mmd"), "stateDiagram-v2\n [*] --> Cart\n Cart --> Paid : pay\n");
759
+ for (const args of [
760
+ ["from-spec", "Pya.tla"],
761
+ ["from-spec", "Pay.tla", "--expect-broken", "Unpaid"],
762
+ ["from-spec", "Pay.tla", "--fixed", "PayFixd.tla"],
763
+ ["from-chart", "cart.mmd", "--expect-broken", "Unpaid"],
764
+ ]) {
765
+ const r = node("statechart.mjs", args, dir, { TLA2TOOLS: join(dir, "tla2tools.jar") });
766
+ assert.equal(r.status, 2, r.stderr);
767
+ }
768
+ assert.deepEqual(
769
+ readdirSync(dir).filter((f) => f.endsWith("-statechart")),
770
+ [],
771
+ );
772
+ });
773
+
774
+ test("statechart.mjs does not use an output folder that has files that it did not write", () => {
775
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
776
+ writeFileSync(join(dir, "tla2tools.jar"), "");
777
+ writeFileSync(join(dir, "Pay.tla"), "---- MODULE Pay ----\n====\n");
778
+ writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n");
779
+ const env = { TLA2TOOLS: join(dir, "tla2tools.jar") };
780
+ const cwd = node("statechart.mjs", ["from-spec", "Pay.tla", "--out", "."], dir, env);
781
+ assert.equal(cwd.status, 2);
782
+ assert.equal(
783
+ cwd.stderr,
784
+ ". 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",
785
+ );
786
+ const out = join(dir, "Pay-statechart");
787
+ mkdirSync(join(out, "tlc"), { recursive: true });
788
+ writeFileSync(join(out, "chart.mmd"), "kept\n");
789
+ writeFileSync(join(out, ".DS_Store"), "");
790
+ writeFileSync(join(out, "notes.md"), "mine\n");
791
+ const notes = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, env);
792
+ assert.equal(notes.status, 2);
793
+ assert.equal(
794
+ notes.stderr,
795
+ "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",
796
+ );
797
+ assert.deepEqual(readdirSync(dir).sort(), [
798
+ "Pay-statechart",
799
+ "Pay.cfg",
800
+ "Pay.tla",
801
+ "tla2tools.jar",
802
+ ]);
803
+ assert.deepEqual(readdirSync(out).sort(), [".DS_Store", "chart.mmd", "notes.md", "tlc"]);
804
+ assert.equal(readFileSync(join(out, "chart.mmd"), "utf8"), "kept\n");
805
+ });
806
+
807
+ test("statechart.mjs exits 2 and names a helper script that cannot start", () => {
808
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
809
+ writeFileSync(join(dir, "notes-app.mmd"), fixture("sync.mmd"));
810
+ writeFileSync(join(dir, "no-node.cjs"), 'process.execPath = "/nonexistent/node";\n');
811
+ const r = node("statechart.mjs", ["from-chart", "notes-app.mmd"], dir, {
812
+ NODE_OPTIONS: `--require ${join(dir, "no-node.cjs")}`,
813
+ });
814
+ assert.equal(r.status, 2, r.stderr);
815
+ assert.match(r.stderr, /chart-to-tla\.mjs/);
816
+ });
817
+
818
+ test("statechart.mjs names the chart it refuses and the argument it does not know", () => {
819
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
820
+ writeFileSync(
821
+ join(dir, "c.mmd"),
822
+ "stateDiagram-v2\n [*] --> A\n A --> B : go\n %% never Bad: C -- never C\n",
823
+ );
824
+ const refused = node("statechart.mjs", ["from-chart", "c.mmd"], dir);
825
+ assert.equal(refused.status, 2);
826
+ assert.equal(refused.stdout, "");
827
+ assert.equal(refused.stderr, 'c.mmd: never line names "C", which is not a state in the chart\n');
828
+ const unknown = node("statechart.mjs", ["from-chart", "c.mmd", "--fixed", "CFixed.tla"], dir);
829
+ assert.equal(unknown.status, 2);
830
+ assert.equal(
831
+ unknown.stderr,
832
+ "unknown argument --fixed. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n",
833
+ );
834
+ const sameName = node("statechart.mjs", ["from-spec", "C.tla", "--fixed", "fix/C.tla"], dir);
835
+ assert.equal(sameName.status, 2);
836
+ assert.equal(
837
+ sameName.stderr,
838
+ "fix/C.tla needs its own file and module name, such as CFixed.tla.\n",
839
+ );
840
+ const sameAgreed = node("statechart.mjs", ["from-spec", "C.tla", "--against", "a/C.tla"], dir);
841
+ assert.equal(
842
+ sameAgreed.stderr,
843
+ "a/C.tla needs its own file and module name, such as CAgreed.tla.\n",
844
+ );
845
+ for (const [extra, stderr] of [
846
+ [
847
+ ["--fixed", "F.tla", "--against", "A.tla"],
848
+ "use --fixed or --against, not both. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n",
849
+ ],
850
+ [
851
+ ["--label", "the new build"],
852
+ "--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",
853
+ ],
854
+ ]) {
855
+ const r = node("statechart.mjs", ["from-spec", "C.tla", ...extra], dir);
856
+ assert.equal(r.status, 2, extra.join(" "));
857
+ assert.equal(r.stderr, stderr);
858
+ }
859
+ const valueless = node("statechart.mjs", ["from-chart", "c.mmd", "--out"], dir);
860
+ assert.equal(valueless.status, 2);
861
+ assert.equal(
862
+ valueless.stderr,
863
+ "--out needs a value. Run node statechart.mjs from-chart --help for the flags, the output, and the exit codes.\n",
864
+ );
865
+ const bare = node("statechart.mjs", [], dir);
866
+ assert.equal(bare.status, 2);
867
+ assert.equal(
868
+ bare.stderr,
869
+ `Usage: ${FROM_SPEC_USAGE}
870
+ ${FROM_CHART_USAGE}
871
+ Run node statechart.mjs --help for the flags, the output, and the exit codes.
872
+ `,
873
+ );
874
+ });
875
+
876
+ test("every script prints its help with -h or --help, and points at it for an argument it does not know", () => {
877
+ const help = {
878
+ "statechart.mjs": [[], `Usage: ${FROM_SPEC_USAGE}`],
879
+ "statechart.mjs from-spec": [["from-spec"], `Usage: ${FROM_SPEC_USAGE}`],
880
+ "statechart.mjs from-chart": [["from-chart", "notes-app.mmd"], `Usage: ${FROM_CHART_USAGE}`],
881
+ "chart-to-tla.mjs": [[], "Usage: node chart-to-tla.mjs CHART.mmd --module NAME [--dir OUT]"],
882
+ "tlc-to-chart.mjs": [[], "Usage: node tlc-to-chart.mjs --dot FILE --map FILE [options]"],
883
+ "compare-charts.mjs": [[], "Usage: node compare-charts.mjs EXPECTED.mmd ACTUAL.mmd"],
884
+ };
885
+ for (const [name, [before, usage]] of Object.entries(help)) {
886
+ const script = name.split(" ")[0];
887
+ for (const flag of ["-h", "--help"]) {
888
+ const r = node(script, [...before, flag], here);
889
+ assert.equal(r.status, 0, `${name} ${flag}`);
890
+ assert.equal(r.stderr, "");
891
+ assert.equal(r.stdout.split("\n")[0], usage);
892
+ assert.match(r.stdout, /\nExit codes\n {2}0 .*\n(.*\n)* {2}1 .*\n(.*\n)* {2}2 /);
893
+ }
894
+ }
895
+ const top = node("statechart.mjs", ["--help"], here).stdout.split("\n");
896
+ assert.equal(top[1], ` ${FROM_CHART_USAGE}`);
897
+ assert.equal(top[2], " node statechart.mjs [from-spec|from-chart] --help");
898
+ for (const flag of [
899
+ "--fixed FIXED.tla",
900
+ "--label TEXT",
901
+ "--map FILE",
902
+ "--module NAME",
903
+ "--title TEXT",
904
+ "--out DIR",
905
+ "--png",
906
+ "-h, --help",
907
+ ])
908
+ assert.ok(
909
+ top.some((l) => l.startsWith(` ${flag} `)),
910
+ flag,
911
+ );
912
+ for (const [script, args, stderr] of [
913
+ [
914
+ "statechart.mjs",
915
+ ["from-spec", "--nope"],
916
+ "unknown argument --nope. Run node statechart.mjs from-spec --help for the flags, the output, and the exit codes.\n",
917
+ ],
918
+ [
919
+ "chart-to-tla.mjs",
920
+ ["--nope"],
921
+ "unknown argument --nope. Run node chart-to-tla.mjs --help for the flags.\n",
922
+ ],
923
+ [
924
+ "tlc-to-chart.mjs",
925
+ ["--nope"],
926
+ "unknown argument --nope. Run node tlc-to-chart.mjs --help for the flags.\n",
927
+ ],
928
+ [
929
+ "tlc-to-chart.mjs",
930
+ ["--dot", "a.dot", "--map", "a.map.mjs", "--regions"],
931
+ "--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",
932
+ ],
933
+ [
934
+ "tlc-to-chart.mjs",
935
+ ["--dot", "a.dot", "--map", "a.map.mjs", "--proof", "p.mmd"],
936
+ "--proof writes the regions proof; it needs --regions. Run node tlc-to-chart.mjs --help for the flags.\n",
937
+ ],
938
+ [
939
+ "compare-charts.mjs",
940
+ ["--nope"],
941
+ "unknown argument --nope. Run node compare-charts.mjs --help for the flags.\n",
942
+ ],
943
+ ]) {
944
+ const r = node(script, args, here);
945
+ assert.equal(r.status, 2, script);
946
+ assert.equal(r.stdout, "");
947
+ assert.equal(r.stderr, stderr);
948
+ }
949
+ });
950
+
951
+ const tools = (() => {
952
+ try {
953
+ return findTlc();
954
+ } catch {
955
+ return null;
956
+ }
957
+ })();
958
+ const needsTlc = { skip: tools ? false : "cannot find TLC" };
959
+
960
+ function tlc(dir, name) {
961
+ const r = spawnSync(
962
+ tools.java,
963
+ [
964
+ "-cp",
965
+ tools.jar,
966
+ "tlc2.TLC",
967
+ "-metadir",
968
+ mkdtempSync(join(tmpdir(), "tlc-")),
969
+ "-deadlock",
970
+ "-continue",
971
+ "-dump",
972
+ "dot,actionlabels",
973
+ `${name}.dot`,
974
+ "-config",
975
+ `${name}.cfg`,
976
+ `${name}.tla`,
977
+ ],
978
+ { cwd: dir, encoding: "utf8" },
979
+ );
980
+ writeFileSync(join(dir, `${name}.out`), r.stdout);
981
+ return r;
982
+ }
983
+
984
+ test("from-chart checks the agreed chart and draws it back unchanged", needsTlc, () => {
985
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
986
+ writeFileSync(join(dir, "upload.mmd"), fixture("upload.mmd"));
987
+ const r = node("statechart.mjs", ["from-chart", "upload.mmd"], dir);
988
+ assert.equal(r.status, 0, r.stderr);
989
+ assert.equal(
990
+ r.stdout,
991
+ `Wrote Upload.tla, Upload.cfg, and Upload.map.mjs from upload.mmd.
992
+ TLC on Upload.tla:
993
+ TypeOK holds.
994
+
995
+ Chart: 5 states and 8 transitions from 5 model states.
996
+ 5 work, 0 break a rule.
997
+
998
+ No rule is broken.
999
+
1000
+ The spec draws upload.mmd back unchanged.
1001
+
1002
+ Wrote chart.mmd, stories.md, summary.json in Upload-statechart.
1003
+ `,
1004
+ );
1005
+ assert.equal(r.stderr, "");
1006
+ });
1007
+
1008
+ test("TLC reads a label with a guarded and an unguarded move in one region", needsTlc, () => {
1009
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1010
+ writeFileSync(join(dir, "switch.mmd"), switchLamp);
1011
+ const r = node("statechart.mjs", ["from-chart", "switch.mmd"], dir);
1012
+ assert.equal(r.status, 0, r.stdout + r.stderr);
1013
+ assert.match(r.stdout, /\nThe spec draws switch\.mmd back unchanged\.\n/);
1014
+ });
1015
+
1016
+ const syncStory = `## 1. Offline | Synced
1017
+
1018
+ Rule broken: You never see "Synced" while you are offline.
1019
+ This is the only way to get here in the model.
1020
+
1021
+ 0. Start: Online | Synced
1022
+ 1. The network drops. Now: Offline | Waiting to sync
1023
+ 2. The server confirms. Now: Offline | Synced
1024
+ `;
1025
+
1026
+ test("from-chart finds the design bug, and from-spec with the fix marks it fixed", needsTlc, () => {
1027
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1028
+ writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd"));
1029
+ const today = node("statechart.mjs", ["from-chart", "sync.mmd"], dir);
1030
+ assert.equal(today.status, 1, today.stderr);
1031
+ assert.equal(
1032
+ today.stdout,
1033
+ `Wrote Sync.tla, Sync.cfg, and Sync.map.mjs from sync.mmd.
1034
+ TLC on Sync.tla:
1035
+ TypeOK holds.
1036
+ NeverSyncedWhileOffline is broken.
1037
+
1038
+ Chart: 4 states and 8 transitions from 4 model states.
1039
+ 3 work, 1 breaks a rule.
1040
+
1041
+ ${syncStory}
1042
+ The spec draws sync.mmd back unchanged.
1043
+
1044
+ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart.
1045
+ `,
1046
+ );
1047
+ assert.match(
1048
+ readFileSync(join(dir, "Sync-statechart", "bug-1.mmd"), "utf8"),
1049
+ /: 2\. the server confirms\n/,
1050
+ );
1051
+
1052
+ const spec = readFileSync(join(dir, "Sync.tla"), "utf8");
1053
+ const guarded = spec
1054
+ .replace("TheServerConfirms ==\n", 'TheServerConfirms ==\n /\\ connection = "Online"\n')
1055
+ .replace("MODULE Sync", "MODULE SyncFixed");
1056
+ assert.notEqual(guarded, spec);
1057
+ writeFileSync(join(dir, "SyncFixed.tla"), guarded);
1058
+ const fixed = node("statechart.mjs", ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"], dir);
1059
+ assert.equal(fixed.status, 1, fixed.stderr);
1060
+ assert.equal(
1061
+ fixed.stdout,
1062
+ `TLC on Sync.tla:
1063
+ TypeOK holds.
1064
+ NeverSyncedWhileOffline is broken.
1065
+ TLC on SyncFixed.tla:
1066
+ TypeOK holds.
1067
+ NeverSyncedWhileOffline holds.
1068
+
1069
+ Chart: 4 states and 9 transitions from 4 model states.
1070
+ 3 work today, 0 still wrong with the fix, 1 fixed or new with the fix.
1071
+
1072
+ ${syncStory}
1073
+ With the fix, step 2 (the server confirms) cannot happen, so you stay at: Offline | Waiting to sync.
1074
+
1075
+ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in Sync-statechart.
1076
+ `,
1077
+ );
1078
+ const summary = JSON.parse(readFileSync(join(dir, "Sync-statechart", "summary.json"), "utf8"));
1079
+ assert.deepEqual(summary.chartStatus, { works: 3, wrong: 0, fixed: 1 });
1080
+ assert.equal(summary.classes[0].fix, "Offline | Waiting to sync");
1081
+
1082
+ const map = readFileSync(join(dir, "Sync.map.mjs"), "utf8");
1083
+ writeFileSync(join(dir, "Sync.map.mjs"), `${map}export const group = () => "Your notes";\n`);
1084
+ const grouped = node(
1085
+ "statechart.mjs",
1086
+ ["from-spec", "Sync.tla", "--fixed", "SyncFixed.tla"],
1087
+ dir,
1088
+ );
1089
+ assert.equal(grouped.status, 1, grouped.stderr);
1090
+ const lines = readFileSync(join(dir, "Sync-statechart", "chart.mmd"), "utf8").split("\n");
1091
+ assert.deepEqual(
1092
+ lines.filter((l) => /^ {2}(classDef|class) |legend/.test(l)),
1093
+ [
1094
+ " classDef plainFixed fill:#f4f6f7,stroke:#1e8449,stroke-width:4px,color:#145a32",
1095
+ " classDef group0 fill:#d6eaf8,stroke:#6b757c,color:#1b2631",
1096
+ " classDef group0Fixed fill:#d6eaf8,stroke:#1e8449,stroke-width:4px,color:#145a32",
1097
+ ' state "Legend" as legend {',
1098
+ ' state "Your notes" as legendGroup0',
1099
+ ' state "Fixed or new with the fix" as legendFixed',
1100
+ " class legendGroup0 group0",
1101
+ " class legendFixed plainFixed",
1102
+ " class s0 group0Fixed",
1103
+ " class s1 group0",
1104
+ " class s2 group0",
1105
+ " class s3 group0",
1106
+ ],
1107
+ );
1108
+ writeFileSync(join(dir, "Sync.map.mjs"), map);
1109
+
1110
+ writeFileSync(join(dir, "Sync.tla"), guarded.replace("MODULE SyncFixed", "MODULE Sync"));
1111
+ const coded = node("statechart.mjs", ["from-chart", "sync.mmd"], dir);
1112
+ assert.equal(coded.status, 1, coded.stderr);
1113
+ assert.equal(
1114
+ coded.stdout,
1115
+ `Kept Sync.tla. Delete it to write the spec again from sync.mmd.
1116
+ TLC on Sync.tla:
1117
+ TypeOK holds.
1118
+ NeverSyncedWhileOffline holds.
1119
+
1120
+ Chart: 3 states and 5 transitions from 3 model states.
1121
+ 3 work, 0 break a rule.
1122
+
1123
+ No rule is broken.
1124
+
1125
+ The spec does not draw sync.mmd:
1126
+ states only in EXPECTED (no path from the start reaches them in the spec, or the spec renamed them):
1127
+ Offline | Synced
1128
+ transitions only in EXPECTED (a guard in the spec blocks them, or the spec renamed the step):
1129
+ Offline | Synced --> Offline | Waiting to sync : the network drops
1130
+ Offline | Synced --> Offline | Waiting to sync : you edit a note
1131
+ Offline | Synced --> Online | Synced : the network returns
1132
+ Offline | Waiting to sync --> Offline | Synced : the server confirms
1133
+ 5 difference(s)
1134
+
1135
+ Wrote chart.mmd, stories.md, summary.json in Sync-statechart.
1136
+ `,
1137
+ );
1138
+ assert.deepEqual(readdirSync(join(dir, "Sync-statechart")).sort(), [
1139
+ "chart.mmd",
1140
+ "stories.md",
1141
+ "summary.json",
1142
+ "tlc",
1143
+ ]);
1144
+ });
1145
+
1146
+ test(
1147
+ "--png exits 2 unless every chart renders, even with --expect-broken, and keeps the files it wrote",
1148
+ needsTlc,
1149
+ () => {
1150
+ const args = ["from-chart", "sync.mmd", "--expect-broken", "NeverSyncedWhileOffline", "--png"];
1151
+ const bin = mkdtempSync(join(tmpdir(), "fake-mmdc-"));
1152
+ const launch = "Error: Failed to launch the browser process!";
1153
+ writeFileSync(
1154
+ join(bin, "mmdc"),
1155
+ `#!/bin/sh
1156
+ while [ $# -gt 0 ]; do
1157
+ case "$1" in -i) chart=$2 ;; -o) png=$2 ;; esac
1158
+ shift
1159
+ done
1160
+ for c in $UNRENDERED; do
1161
+ case "$chart" in */$c.mmd) echo "${launch}" >&2; exit 1 ;; esac
1162
+ done
1163
+ echo png > "$png"
1164
+ `,
1165
+ { mode: 0o755 },
1166
+ );
1167
+ for (const [unrendered, status, written] of [
1168
+ ["chart bug-1", 2, ["chart.mmd", "bug-1.mmd"]],
1169
+ ["bug-1", 2, ["chart.mmd", "bug-1.mmd", "chart.png"]],
1170
+ ["", 0, ["chart.mmd", "bug-1.mmd", "chart.png", "bug-1.png"]],
1171
+ ]) {
1172
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1173
+ writeFileSync(join(dir, "sync.mmd"), fixture("sync.mmd"));
1174
+ const r = node("statechart.mjs", args, dir, {
1175
+ PATH: `${bin}${delimiter}${process.env.PATH}`,
1176
+ UNRENDERED: unrendered,
1177
+ });
1178
+ assert.equal(r.status, status, `${unrendered}: ${r.stdout}${r.stderr}`);
1179
+ assert.equal(
1180
+ r.stderr,
1181
+ unrendered
1182
+ .split(" ")
1183
+ .filter(Boolean)
1184
+ .map((c) => `could not render Sync-statechart/${c}.mmd: ${launch}\n`)
1185
+ .join(""),
1186
+ );
1187
+ assert.match(r.stdout, /\nBroken as expected: NeverSyncedWhileOffline\.\n/);
1188
+ assert.equal(
1189
+ r.stdout.split("\n").at(-2),
1190
+ `Wrote ${[...written, "stories.md", "summary.json"].join(", ")} in Sync-statechart.`,
1191
+ );
1192
+ assert.deepEqual(
1193
+ readdirSync(join(dir, "Sync-statechart")).sort(),
1194
+ [...written, "stories.md", "summary.json", "tlc"].sort(),
1195
+ );
1196
+ }
1197
+ },
1198
+ );
1199
+
1200
+ const pay = (name, charge) => `---- MODULE ${name} ----
1201
+ EXTENDS Naturals
1202
+ VARIABLES screen, charges
1203
+ vars == <<screen, charges>>
1204
+ Init == screen = "Cart" /\\ charges = 0
1205
+ Pay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED charges
1206
+ Charge == ${charge}
1207
+ Refund == charges = 2 /\\ charges' = 1 /\\ screen' = "Refunded"
1208
+ Next == Pay \\/ Charge \\/ Refund
1209
+ Spec == Init /\\ [][Next]_vars
1210
+ NoDoubleCharge == charges <= 1
1211
+ ====
1212
+ `;
1213
+ const payMap = `export const readerActions = ["Pay"];
1214
+ export const state = (v) => v.screen + ["", ", charged", ", charged twice"][v.charges];
1215
+ export const step = (action) => ({ Pay: "you pay", Charge: "the bank charges you", Refund: "the bank refunds you" })[action] ?? "";
1216
+ export const cause = () => "each charge request charges again";
1217
+ `;
1218
+
1219
+ test(
1220
+ "a state reached only after a bug takes the bug's color, and the fix edge leaves the state before the changed step",
1221
+ needsTlc,
1222
+ () => {
1223
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1224
+ const cfg = "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n";
1225
+ writeFileSync(
1226
+ join(dir, "Pay.tla"),
1227
+ pay(
1228
+ "Pay",
1229
+ `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1230
+ ),
1231
+ );
1232
+ writeFileSync(
1233
+ join(dir, "PayFixed.tla"),
1234
+ pay(
1235
+ "PayFixed",
1236
+ `screen = "Paying" /\\ charges < 2 /\\ IF charges = 0 THEN charges' = 1 /\\ UNCHANGED screen ELSE screen' = "Paid" /\\ UNCHANGED charges`,
1237
+ ),
1238
+ );
1239
+ writeFileSync(join(dir, "Pay.cfg"), cfg);
1240
+ writeFileSync(join(dir, "PayFixed.cfg"), cfg);
1241
+ writeFileSync(join(dir, "pay.map.mjs"), payMap);
1242
+ assert.match(tlc(dir, "Pay").stdout, /Invariant NoDoubleCharge is violated/);
1243
+ assert.doesNotMatch(tlc(dir, "PayFixed").stdout, /is violated/);
1244
+
1245
+ const statusOf = (chart) => {
1246
+ const names = new Map(
1247
+ [...chart.matchAll(/^ {2}state "([^"]+)" as (s\d+)$/gm)].map((m) => [
1248
+ m[2],
1249
+ m[1].replace(/<br\/>/g, " "),
1250
+ ]),
1251
+ );
1252
+ return Object.fromEntries(
1253
+ [...chart.matchAll(/^ {2}class (s\d+) (\w+)$/gm)].map((m) => [names.get(m[1]), m[2]]),
1254
+ );
1255
+ };
1256
+ const args = [
1257
+ "--dot",
1258
+ "Pay.dot",
1259
+ "--map",
1260
+ "pay.map.mjs",
1261
+ "--traces",
1262
+ "Pay.out",
1263
+ "--highlight",
1264
+ "0",
1265
+ "--out",
1266
+ "chart.mmd",
1267
+ ];
1268
+ assert.equal(node("tlc-to-chart.mjs", args, dir).status, 0);
1269
+ assert.deepEqual(statusOf(readFileSync(join(dir, "chart.mmd"), "utf8")), {
1270
+ Cart: "works",
1271
+ Paying: "works",
1272
+ "Paying, charged": "works",
1273
+ "Paying, charged twice": "wrong",
1274
+ "Refunded, charged": "wrong",
1275
+ });
1276
+
1277
+ const withFix = [...args, "--fixed-dot", "PayFixed.dot", "--fixed-traces", "PayFixed.out"];
1278
+ assert.equal(node("tlc-to-chart.mjs", withFix, dir).status, 0);
1279
+ const chart = readFileSync(join(dir, "chart.mmd"), "utf8");
1280
+ assert.deepEqual(statusOf(chart), {
1281
+ Cart: "works",
1282
+ "Paid, charged": "fixed",
1283
+ Paying: "works",
1284
+ "Paying, charged": "works",
1285
+ "Paying, charged twice": "fixed",
1286
+ "Refunded, charged": "fixed",
1287
+ });
1288
+ assert.deepEqual(
1289
+ [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]),
1290
+ ["Works today", "Fixed or new with the fix"],
1291
+ );
1292
+ assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(fix\): the fix\n/m);
1293
+ const fixEdges = parseMermaid(chart).regions.Main.transitions.filter((t) =>
1294
+ t.label.startsWith("with the fix, "),
1295
+ );
1296
+ assert.deepEqual(
1297
+ fixEdges.map((t) => `${t.from} --> ${t.to} : ${t.label}`),
1298
+ ["Paying, charged --> Paid, charged : with the fix, the bank charges you"],
1299
+ );
1300
+ },
1301
+ );
1302
+
1303
+ test(
1304
+ "from-spec writes a mapping to fill in, then draws each bug in every view the mapping has",
1305
+ needsTlc,
1306
+ () => {
1307
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1308
+ writeFileSync(
1309
+ join(dir, "Pay.tla"),
1310
+ pay(
1311
+ "Pay",
1312
+ `screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED screen`,
1313
+ ),
1314
+ );
1315
+ writeFileSync(
1316
+ join(dir, "Pay.cfg"),
1317
+ "SPECIFICATION Spec\n\\* The reader's rule.\nINVARIANTS\n NoDoubleCharge\n",
1318
+ );
1319
+ const first = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
1320
+ assert.equal(first.status, 2);
1321
+ assert.equal(first.stdout, "TLC on Pay.tla:\n NoDoubleCharge is broken.\n");
1322
+ assert.equal(
1323
+ first.stderr,
1324
+ "\nWrote Pay.map.mjs. Fill in its labels, then run this command again.\n",
1325
+ );
1326
+ const scaffold = readFileSync(join(dir, "Pay.map.mjs"), "utf8");
1327
+ assert.match(scaffold, /^\/\/ Variables: screen, charges\.$/m);
1328
+ assert.match(
1329
+ scaffold,
1330
+ /^const STEPS = \{\n\tCharge: "Charge",\n\tPay: "Pay",\n\tRefund: "Refund",\n\};$/m,
1331
+ );
1332
+ assert.equal(existsSync(join(dir, "Pay-statechart")), false);
1333
+
1334
+ writeFileSync(
1335
+ join(dir, "Pay.map.mjs"),
1336
+ `${payMap}export const detail = (v) => \`charges = \${v.charges}\`;\n`,
1337
+ );
1338
+ const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
1339
+ assert.equal(r.status, 1, r.stderr);
1340
+ assert.equal(
1341
+ r.stdout,
1342
+ `TLC on Pay.tla:
1343
+ NoDoubleCharge is broken.
1344
+
1345
+ Chart: 5 states and 4 transitions from 5 model states.
1346
+ 3 work, 2 break a rule.
1347
+
1348
+ ## 1. Paying, charged twice
1349
+
1350
+ Rule broken: \`NoDoubleCharge\`
1351
+ Why: each charge request charges again.
1352
+ This is the only way to get here in the model.
1353
+
1354
+ 0. Start: Cart
1355
+ 1. You pay. Now: Paying
1356
+ 2. The bank charges you. Now: Paying, charged
1357
+ 3. The bank charges you. Now: Paying, charged twice
1358
+
1359
+ Wrote chart.mmd, bug-1.mmd, bug-1-developer.mmd, bug-1-layered.mmd, stories.md, summary.json in Pay-statechart.
1360
+ `,
1361
+ );
1362
+ assert.match(
1363
+ readFileSync(join(dir, "Pay-statechart", "bug-1-layered.mmd"), "utf8"),
1364
+ /state "Paying, charged twice" as g\d \{\n {4}state "charges = 2" as s\d\n/,
1365
+ );
1366
+ assert.deepEqual(readdirSync(join(dir, "Pay-statechart")).sort(), [
1367
+ "bug-1-developer.mmd",
1368
+ "bug-1-layered.mmd",
1369
+ "bug-1.mmd",
1370
+ "chart.mmd",
1371
+ "stories.md",
1372
+ "summary.json",
1373
+ "tlc",
1374
+ ]);
1375
+ assert.equal(
1376
+ readFileSync(join(dir, "Pay-statechart", "tlc", "Pay.cfg"), "utf8"),
1377
+ "SPECIFICATION Spec\n",
1378
+ );
1379
+ assert.equal(
1380
+ readFileSync(join(dir, "Pay-statechart", "tlc", "Pay-NoDoubleCharge.cfg"), "utf8"),
1381
+ "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\nCONSTRAINT NoDoubleCharge\n",
1382
+ );
1383
+ },
1384
+ );
1385
+
1386
+ test("a rule broken by a start state marks that state wrong", needsTlc, () => {
1387
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1388
+ writeFileSync(
1389
+ join(dir, "Start.tla"),
1390
+ `---- MODULE Start ----
1391
+ VARIABLES conn, badge
1392
+ vars == <<conn, badge>>
1393
+ Init == conn \\in {"Online", "Offline"} /\\ badge = "Synced"
1394
+ Drop == conn = "Online" /\\ conn' = "Offline" /\\ badge' = "Waiting"
1395
+ Next == Drop
1396
+ Spec == Init /\\ [][Next]_vars
1397
+ NeverSyncedWhileOffline == ~(conn = "Offline" /\\ badge = "Synced")
1398
+ ====
1399
+ `,
1400
+ );
1401
+ writeFileSync(join(dir, "Start.cfg"), "SPECIFICATION Spec\nINVARIANT NeverSyncedWhileOffline\n");
1402
+ writeFileSync(
1403
+ join(dir, "start.map.mjs"),
1404
+ 'export const state = (v) => `${v.conn} | ${v.badge}`;\nexport const step = () => "the network drops";\n',
1405
+ );
1406
+ assert.match(tlc(dir, "Start").stdout, /is violated by the initial state/);
1407
+ const args = [
1408
+ "--dot",
1409
+ "Start.dot",
1410
+ "--map",
1411
+ "start.map.mjs",
1412
+ "--traces",
1413
+ "Start.out",
1414
+ "--summary",
1415
+ "summary.json",
1416
+ "--out",
1417
+ "chart.mmd",
1418
+ ];
1419
+ const r = node("tlc-to-chart.mjs", args, dir);
1420
+ assert.equal(r.status, 0, r.stderr);
1421
+ const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8"));
1422
+ assert.deepEqual(
1423
+ summary.classes.map((c) => [c.end, c.readerSteps]),
1424
+ [["Offline | Synced", 0]],
1425
+ );
1426
+ assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 0 });
1427
+ });
1428
+
1429
+ const edit = (name, fixed) => `---- MODULE ${name} ----
1430
+ VARIABLES s, flashed
1431
+ vars == <<s, flashed>>
1432
+ Init == s = "Idle" /\\ flashed = FALSE
1433
+ Tap == s = "Idle" /\\ s' = "Saving" /\\ UNCHANGED flashed
1434
+ Lose == s = "Saving" /\\ s' = "Lost edit" /\\ UNCHANGED flashed
1435
+ Flash == s = "Saving" /\\ s' = "Flash" /\\ flashed' = ${fixed ? "TRUE" : "FALSE"}
1436
+ Back == s \\in {"Lost edit", "Flash"} /\\ s' = "Idle" /\\ flashed' = FALSE
1437
+ Next == Tap \\/ Flash \\/ Back${fixed ? "" : " \\/ Lose"}
1438
+ Spec == Init /\\ [][Next]_vars
1439
+ NoLostEdit == s # "Lost edit" /\\ ~(s = "Flash" /\\ flashed)
1440
+ ====
1441
+ `;
1442
+
1443
+ test(
1444
+ "a bug that only the fixed model has is numbered after the current bugs and drawn wrong",
1445
+ needsTlc,
1446
+ () => {
1447
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1448
+ const cfg = "SPECIFICATION Spec\nINVARIANT NoLostEdit\n";
1449
+ for (const [name, fixed] of [
1450
+ ["Edit", false],
1451
+ ["EditFixed", true],
1452
+ ]) {
1453
+ writeFileSync(join(dir, `${name}.tla`), edit(name, fixed));
1454
+ writeFileSync(join(dir, `${name}.cfg`), cfg);
1455
+ assert.match(tlc(dir, name).stdout, /Invariant NoLostEdit is violated/);
1456
+ }
1457
+ writeFileSync(
1458
+ join(dir, "edit.map.mjs"),
1459
+ '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',
1460
+ );
1461
+ const args = [
1462
+ "--dot",
1463
+ "Edit.dot",
1464
+ "--map",
1465
+ "edit.map.mjs",
1466
+ "--traces",
1467
+ "Edit.out",
1468
+ "--fixed-dot",
1469
+ "EditFixed.dot",
1470
+ "--fixed-traces",
1471
+ "EditFixed.out",
1472
+ ];
1473
+ const r = node(
1474
+ "tlc-to-chart.mjs",
1475
+ [
1476
+ ...args,
1477
+ "--cover",
1478
+ "--highlight",
1479
+ "0",
1480
+ "--story",
1481
+ "story.md",
1482
+ "--summary",
1483
+ "summary.json",
1484
+ "--out",
1485
+ "chart.mmd",
1486
+ ],
1487
+ dir,
1488
+ );
1489
+ assert.equal(r.status, 0, r.stderr);
1490
+ assert.match(r.stderr, /1 bug\(s\), 1 new with the fix/);
1491
+ const summary = JSON.parse(readFileSync(join(dir, "summary.json"), "utf8"));
1492
+ assert.deepEqual(
1493
+ summary.classes.map((c) => [c.n, c.end, c.fix]),
1494
+ [
1495
+ [1, "Lost edit", "Saving"],
1496
+ [2, "Flash", "new with the fix"],
1497
+ ],
1498
+ );
1499
+ assert.deepEqual(summary.chartStatus, { works: 2, wrong: 1, fixed: 1 });
1500
+ assert.match(readFileSync(join(dir, "story.md"), "utf8"), /^## 2\. New with the fix: Flash$/m);
1501
+ const two = node(
1502
+ "tlc-to-chart.mjs",
1503
+ [...args, "--highlight", "2", "--path-only", "--out", "bug2.mmd"],
1504
+ dir,
1505
+ );
1506
+ assert.equal(two.status, 0, two.stderr);
1507
+ assert.match(readFileSync(join(dir, "bug2.mmd"), "utf8"), /: 2\. the screen flashes\n/);
1508
+ },
1509
+ );
1510
+
1511
+ function flatEdges(mmd) {
1512
+ const names = new Map();
1513
+ const members = new Map();
1514
+ let box = null;
1515
+ for (const line of mmd.split("\n")) {
1516
+ const opens = /^ {2}state "([^"]+)" as (g\d+) \{$/.exec(line);
1517
+ const state = /^ +state "([^"]+)" as (s\d+)$/.exec(line);
1518
+ const said = (s) => s.replaceAll("<br/>", " ");
1519
+ if (opens) {
1520
+ box = { id: opens[2], name: said(opens[1]) };
1521
+ members.set(box.id, []);
1522
+ } else if (line === " }") box = null;
1523
+ else if (state) {
1524
+ names.set(state[2], box ? `${box.name}: ${said(state[1])}` : said(state[1]));
1525
+ if (box) members.get(box.id).push(state[2]);
1526
+ }
1527
+ }
1528
+ return [...mmd.matchAll(/^ {2}([gs]\d+) --> (s\d+) : (.*)$/gm)]
1529
+ .flatMap(([, from, to, label]) =>
1530
+ (members.get(from) ?? [from]).map((f) => `${names.get(f)} --> ${names.get(to)} : ${label}`),
1531
+ )
1532
+ .sort();
1533
+ }
1534
+
1535
+ test(
1536
+ "states of one context sit in one box, and an arrow from the box's border stands for the same step from each",
1537
+ needsTlc,
1538
+ () => {
1539
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1540
+ writeFileSync(
1541
+ join(dir, "Window.tla"),
1542
+ `---- MODULE Window ----
1543
+ VARIABLES where, reply
1544
+ vars == <<where, reply>>
1545
+ Init == where = "Starting" /\\ reply = "none"
1546
+ Boot == where = "Starting" /\\ where' = "Chat open" /\\ UNCHANGED reply
1547
+ Ask == where = "Chat open" /\\ reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where
1548
+ Answer == reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where
1549
+ Close == where = "Chat open" /\\ where' = "Window closed" /\\ UNCHANGED reply
1550
+ Open == where = "Window closed" /\\ where' = "Chat open" /\\ reply' = "none"
1551
+ Next == Boot \\/ Ask \\/ Answer \\/ Close \\/ Open
1552
+ Spec == Init /\\ [][Next]_vars
1553
+ NoReplyWhileClosed == ~(where = "Window closed" /\\ reply = "shown")
1554
+ ====
1555
+ `,
1556
+ );
1557
+ writeFileSync(join(dir, "Window.cfg"), "SPECIFICATION Spec\nINVARIANT NoReplyWhileClosed\n");
1558
+ writeFileSync(
1559
+ join(dir, "window.map.mjs"),
1560
+ `const R = { none: "no question", pending: "reply on its way", shown: "reply shown" };
1561
+ export const state = (v) => \`\${v.where}: \${v.where === "Starting" ? "loading" : R[v.reply]}\`;
1562
+ 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];
1563
+ `,
1564
+ );
1565
+ assert.match(tlc(dir, "Window").stdout, /Invariant NoReplyWhileClosed is violated/);
1566
+ const args = ["--dot", "Window.dot", "--map", "window.map.mjs", "--traces", "Window.out"];
1567
+ const draw = (extra, out) => {
1568
+ const r = node("tlc-to-chart.mjs", [...args, ...extra, "--out", out], dir);
1569
+ assert.equal(r.status, 0, r.stderr);
1570
+ return readFileSync(join(dir, out), "utf8");
1571
+ };
1572
+ const boxed = draw([], "boxed.mmd");
1573
+ const flat = draw(["--flat"], "flat.mmd");
1574
+
1575
+ assert.match(
1576
+ boxed,
1577
+ /^ {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,
1578
+ );
1579
+ assert.deepEqual(
1580
+ boxed.split("\n").filter((l) => /you open the window/.test(l)),
1581
+ [" g1 --> s0 : you open the window"],
1582
+ );
1583
+ assert.equal(flat.split("\n").filter((l) => /you open the window/.test(l)).length, 3);
1584
+ assert.doesNotMatch(flat, /as g\d+ \{/);
1585
+ assert.deepEqual(flatEdges(boxed), flatEdges(flat));
1586
+ assert.match(boxed, /^ {2}class s5 wrongPath$/m);
1587
+ assert.match(boxed, /^ {2}s\d --> s5 : 4\. /m);
1588
+ assert.match(boxed, /state "Breaks a rule" as legendWrong/);
1589
+ },
1590
+ );
1591
+
1592
+ const save = (name, code) => `---- MODULE ${name} ----
1593
+ VARIABLES screen, note${code ? ", retries" : ""}
1594
+ vars == <<screen, note${code ? ", retries" : ""}>>
1595
+ Init == screen = "Editing" /\\ note = [text |-> "draft"${code ? `, rev |-> 0] /\\ retries = 0` : "]"}
1596
+ Save == screen = "Editing" /\\ screen' = "Saving" /\\ UNCHANGED <<note${code ? ", retries" : ""}>>
1597
+ Confirm == screen = "Saving" /\\ screen' = "Saved" /\\ note' = [text |-> "stored"${code ? `, rev |-> 1] /\\ UNCHANGED retries` : "]"}
1598
+ Edit == screen = "Saved" /\\ screen' = "Editing" /\\ note' = [note EXCEPT !.text = "draft"]${code ? " /\\ retries' = 0" : ""}
1599
+ ${
1600
+ code
1601
+ ? `Retry == screen = "Saving" /\\ retries = 0 /\\ retries' = 1 /\\ UNCHANGED <<screen, note>>
1602
+ TimeOut == screen = "Saving" /\\ retries = 1 /\\ screen' = "Saved" /\\ UNCHANGED <<note, retries>>
1603
+ Next == Save \\/ Retry \\/ Confirm \\/ TimeOut \\/ Edit`
1604
+ : "Next == Save \\/ Confirm \\/ Edit"
1605
+ }
1606
+ Spec == Init /\\ [][Next]_vars
1607
+ TypeOK == screen \\in {"Editing", "Saving", "Saved"}
1608
+ \\* You never see "Saved" while the note is still a draft.
1609
+ NoFalseSaved == ~(screen = "Saved" /\\ note.text = "draft")
1610
+ ====
1611
+ `;
1612
+
1613
+ test(
1614
+ "from-spec --against compares the code with the spec on the parts both have, and --expect-broken sets the exit code",
1615
+ needsTlc,
1616
+ () => {
1617
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1618
+ writeFileSync(join(dir, "SaveCode.tla"), save("SaveCode", true));
1619
+ writeFileSync(join(dir, "SaveAgreed.tla"), save("SaveAgreed", false));
1620
+ writeFileSync(
1621
+ join(dir, "SaveCode.cfg"),
1622
+ "SPECIFICATION Spec\nINVARIANT TypeOK\nINVARIANT NoFalseSaved\n",
1623
+ );
1624
+ writeFileSync(
1625
+ join(dir, "SaveCode.map.mjs"),
1626
+ `export const readerActions = ["Save", "Edit"];
1627
+ export const state = (v) => \`\${v.screen}, \${v.note.text}\`;
1628
+ 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] ?? "";
1629
+ export const cause = () => "the app stops after one retry and shows Saved anyway";
1630
+ `,
1631
+ );
1632
+ const run = (...extra) =>
1633
+ node(
1634
+ "statechart.mjs",
1635
+ [
1636
+ "from-spec",
1637
+ "SaveCode.tla",
1638
+ "--against",
1639
+ "SaveAgreed.tla",
1640
+ "--label",
1641
+ "the agreed chart",
1642
+ ...extra,
1643
+ ],
1644
+ dir,
1645
+ );
1646
+
1647
+ const unknown = run("--expect-broken", "NoLostNote");
1648
+ assert.equal(unknown.status, 2);
1649
+ assert.equal(
1650
+ unknown.stderr,
1651
+ "--expect-broken NoLostNote: not a rule in SaveCode.cfg, whose rules are TypeOK, NoFalseSaved.\n",
1652
+ );
1653
+
1654
+ const expected = run("--expect-broken", "NoFalseSaved");
1655
+ assert.equal(expected.status, 0, expected.stderr);
1656
+ assert.equal(
1657
+ expected.stdout,
1658
+ `TLC on SaveCode.tla:
1659
+ TypeOK holds.
1660
+ NoFalseSaved is broken (expected).
1661
+ TLC on SaveAgreed.tla:
1662
+ TypeOK holds.
1663
+ NoFalseSaved holds.
1664
+
1665
+ Chart: 4 states and 4 transitions from 10 model states.
1666
+ 3 work, 1 breaks a rule, 0 only in the agreed chart.
1667
+
1668
+ ## 1. Saved, draft
1669
+
1670
+ Rule broken: You never see "Saved" while the note is still a draft.
1671
+ Why: the app stops after one retry and shows Saved anyway.
1672
+ There are 2 ways to get here in the model. This is the shortest.
1673
+
1674
+ 0. Start: Editing, draft
1675
+ 1. You press Save. Now: Saving, draft
1676
+ 2. The save times out. Now: Saved, draft
1677
+
1678
+ In the agreed chart, step 2 (the save times out) cannot happen, so you stay at: Saving, draft.
1679
+
1680
+ Broken as expected: NoFalseSaved.
1681
+
1682
+ Wrote chart.mmd, bug-1.mmd, stories.md, summary.json in SaveCode-statechart.
1683
+ `,
1684
+ );
1685
+ const out = join(dir, "SaveCode-statechart");
1686
+ const chart = readFileSync(join(out, "chart.mmd"), "utf8");
1687
+ assert.match(chart, /^stateDiagram-v2\n {2}%% second model \(against\): the agreed chart\n/m);
1688
+ assert.deepEqual(
1689
+ [...chart.matchAll(/^ {4}state "([^"]+)" as legend\w+$/gm)].map((m) => m[1]),
1690
+ ["Works", "Breaks a rule"],
1691
+ );
1692
+ assert.match(
1693
+ chart,
1694
+ /^ {2}s3 --> s3 : in the agreed chart, the save<br\/>times out cannot happen$/m,
1695
+ );
1696
+ const bug = readFileSync(join(out, "bug-1.mmd"), "utf8");
1697
+ assert.deepEqual([...bug.matchAll(/^ {2}state "([^"]+)" as s\d+$/gm)].map((m) => m[1]).sort(), [
1698
+ "Editing, draft",
1699
+ "Saved, draft",
1700
+ "Saving, draft",
1701
+ ]);
1702
+ const summary = JSON.parse(readFileSync(join(out, "summary.json"), "utf8"));
1703
+ assert.deepEqual(summary.second.role, "against");
1704
+ assert.deepEqual(summary.second.lacks.sort(), ["note.rev", "retries"]);
1705
+ assert.equal(summary.classes[0].against, "Saving, draft");
1706
+ assert.deepEqual(summary.codeLike, []);
1707
+
1708
+ const unexpected = run();
1709
+ assert.equal(unexpected.status, 1, unexpected.stderr);
1710
+ const holds = run("--expect-broken", "TypeOK");
1711
+ assert.equal(holds.status, 1, holds.stderr);
1712
+ assert.match(
1713
+ holds.stdout,
1714
+ /\nBroken but not expected: NoFalseSaved\.\n\nExpected to break but holds: TypeOK\.\n/,
1715
+ );
1716
+ const listed = run("--expect-broken", "NoFalseSaved,TypeOK");
1717
+ assert.equal(listed.status, 1, listed.stderr);
1718
+ assert.match(listed.stdout, /\nExpected to break but holds: TypeOK\.\n/);
1719
+ assert.doesNotMatch(listed.stdout, /Broken but not expected/);
1720
+
1721
+ writeFileSync(
1722
+ join(dir, "code.map.mjs"),
1723
+ readFileSync(join(out, "../SaveCode.map.mjs"), "utf8").replace(
1724
+ '"the app stops after one retry and shows Saved anyway"',
1725
+ '"retryCount hit maxRetries"',
1726
+ ),
1727
+ );
1728
+ const coded = run("--map", "code.map.mjs", "--expect-broken", "NoFalseSaved");
1729
+ assert.equal(coded.status, 0, coded.stderr);
1730
+ assert.match(
1731
+ coded.stdout,
1732
+ /These labels or causes read like code\. Write them in the reader's words in code\.map\.mjs:\n {2}retryCount hit maxRetries\n/,
1733
+ );
1734
+ },
1735
+ );
1736
+
1737
+ test(
1738
+ "from-chart checks a chart whose regions guard each other, and draws it back unchanged",
1739
+ needsTlc,
1740
+ () => {
1741
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1742
+ writeFileSync(join(dir, "lamp.mmd"), lamp);
1743
+ const r = node(
1744
+ "statechart.mjs",
1745
+ ["from-chart", "lamp.mmd", "--expect-broken", "LitWhileOff"],
1746
+ dir,
1747
+ );
1748
+ assert.equal(r.status, 0, r.stdout + r.stderr);
1749
+ assert.match(r.stdout, /\nThe spec draws lamp\.mmd back unchanged\.\n/);
1750
+ },
1751
+ );
1752
+
1753
+ const doorSpec = `---- MODULE Door ----
1754
+ VARIABLES where, reply
1755
+ vars == <<where, reply>>
1756
+ Init == where = "Open" /\\ reply = "none"
1757
+ Ask == reply = "none" /\\ reply' = "pending" /\\ UNCHANGED where
1758
+ Answer == where = "Open" /\\ reply = "pending" /\\ reply' = "shown" /\\ UNCHANGED where
1759
+ Drop == where = "Closed" /\\ reply = "pending" /\\ reply' = "lost" /\\ UNCHANGED where
1760
+ Close == where = "Open" /\\ where' = "Closed" /\\ UNCHANGED reply
1761
+ Reopen == where = "Closed" /\\ where' = "Open" /\\ (reply' = reply \\/ (reply = "pending" /\\ reply' = "shown"))
1762
+ Next == Ask \\/ Answer \\/ Drop \\/ Close \\/ Reopen
1763
+ Spec == Init /\\ [][Next]_vars
1764
+ NoLostReply == reply # "lost"
1765
+ NoShownWhileClosed == ~(where = "Closed" /\\ reply = "shown")
1766
+ ====
1767
+ `;
1768
+
1769
+ const doorMap = `const W = { Open: "Window open", Closed: "Window closed" };
1770
+ const R = { none: "no question", pending: "reply on its way", shown: "reply shown", lost: "reply lost" };
1771
+ export const regionNames = ["Where you are", "Your reply"];
1772
+ export const state = (v) => \`\${W[v.where]}: \${R[v.reply]}\`;
1773
+ 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];
1774
+ `;
1775
+
1776
+ function runDoor(map) {
1777
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1778
+ writeFileSync(join(dir, "Door.tla"), doorSpec);
1779
+ writeFileSync(join(dir, "Door.map.mjs"), map);
1780
+ const out = join(dir, "Door-statechart");
1781
+ const read = (f) => readFileSync(join(out, f), "utf8").replaceAll("<br/>", " ");
1782
+ return (rule) => {
1783
+ writeFileSync(join(dir, "Door.cfg"), `SPECIFICATION Spec\nINVARIANT ${rule}\n`);
1784
+ const r = node("statechart.mjs", ["from-spec", "Door.tla", "--expect-broken", rule], dir);
1785
+ assert.equal(r.status, 0, r.stdout + r.stderr);
1786
+ const every = join(out, "tlc", "regions-all.mmd");
1787
+ return {
1788
+ stdout: r.stdout,
1789
+ chart: read("chart.mmd"),
1790
+ every: existsSync(every) ? read("tlc/regions-all.mmd") : null,
1791
+ bug: read("bug-1.mmd"),
1792
+ out,
1793
+ };
1794
+ };
1795
+ }
1796
+
1797
+ test(
1798
+ "from-spec draws parallel regions only when they combine back into TLC's graph, and boxes otherwise",
1799
+ needsTlc,
1800
+ () => {
1801
+ const run = runDoor(doorMap);
1802
+ const proved = run("NoLostReply");
1803
+ assert.match(
1804
+ proved.stdout,
1805
+ /\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/,
1806
+ );
1807
+ const lines = proved.chart.split("\n");
1808
+ assert.ok(lines.includes(' state "Where you are" as region0 {'));
1809
+ assert.ok(lines.includes(" --"));
1810
+ const guarded = lines.findIndex((l) => /: the reply arrives \[when Window open\]$/.test(l));
1811
+ assert.match(lines[guarded + 1], /^ {6}%% when r0s\d$/);
1812
+ assert.doesNotMatch(proved.chart, /^ {6}r1s\d --> r1s\d : you open the window/m);
1813
+ assert.equal(
1814
+ proved.every
1815
+ .split("\n")
1816
+ .filter((l) => /^ {6}(r1s\d) --> \1 : you open the window \[when Window closed\]$/.test(l))
1817
+ .length,
1818
+ 1,
1819
+ );
1820
+ const again = node(
1821
+ "compare-charts.mjs",
1822
+ ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
1823
+ proved.out,
1824
+ );
1825
+ assert.equal(again.stdout, "same\n", again.stderr);
1826
+
1827
+ const boxed = run("NoShownWhileClosed");
1828
+ assert.equal(boxed.every, null);
1829
+ assert.match(
1830
+ boxed.stdout,
1831
+ /\nIt has no parallel regions, because "Window closed: reply shown" is "Breaks a rule", and its two region states would show "Works"\.\n/,
1832
+ );
1833
+ assert.doesNotMatch(boxed.chart, /^ +--$/m);
1834
+ assert.match(boxed.chart, /^ {2}state "Window closed" as g\d \{$/m);
1835
+ },
1836
+ );
1837
+
1838
+ test(
1839
+ "the mapping's guard words show on an arrow only when every model step behind it has them",
1840
+ needsTlc,
1841
+ () => {
1842
+ const run = runDoor(
1843
+ `${doorMap}export const guard = (a, before) =>
1844
+ ({ Answer: "window open", Drop: "window closed", Ask: before.where === "Open" ? "window open" : "" })[a] ?? "";
1845
+ `,
1846
+ );
1847
+ const proved = run("NoLostReply");
1848
+ const lines = proved.chart.split("\n");
1849
+ for (const words of [
1850
+ /: the reply arrives \[window open\]$/,
1851
+ /: the app drops the reply \[window closed\]$/,
1852
+ ])
1853
+ assert.match(lines[lines.findIndex((l) => words.test(l)) + 1], /^ {6}%% when r0s\d$/);
1854
+ assert.ok(lines.some((l) => /^ {6}r1s\d --> r1s\d : you ask$/.test(l)));
1855
+ const again = node(
1856
+ "compare-charts.mjs",
1857
+ ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
1858
+ proved.out,
1859
+ );
1860
+ assert.equal(again.stdout, "same\n", again.stderr);
1861
+ assert.match(proved.bug, / : \d\. the app drops the reply \[window closed\]$/m);
1862
+
1863
+ const boxed = run("NoShownWhileClosed");
1864
+ assert.match(boxed.chart, / : you ask \[window open\]$/m);
1865
+ assert.match(boxed.chart, / : you ask$/m);
1866
+ },
1867
+ );
1868
+
1869
+ test(
1870
+ "the mapping's timer() writes after N s on timer steps, and refuses a value that is not seconds",
1871
+ needsTlc,
1872
+ () => {
1873
+ const timed = runDoor(
1874
+ `${doorMap}export const timer = (a) => (a === "Drop" ? 30 : undefined);\n`,
1875
+ )("NoLostReply");
1876
+ assert.match(timed.chart, / : after 30 s, the app drops the reply \[when Window closed\]$/m);
1877
+ assert.match(timed.bug, / : \d\. after 30 s, the app drops the reply$/m);
1878
+ const again = node(
1879
+ "compare-charts.mjs",
1880
+ ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
1881
+ timed.out,
1882
+ );
1883
+ assert.equal(again.stdout, "same\n", again.stderr);
1884
+
1885
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
1886
+ writeFileSync(join(dir, "Door.tla"), doorSpec);
1887
+ writeFileSync(join(dir, "Door.cfg"), "SPECIFICATION Spec\nINVARIANT NoLostReply\n");
1888
+ writeFileSync(join(dir, "Door.map.mjs"), `${doorMap}export const timer = () => "soon";\n`);
1889
+ const r = node("statechart.mjs", ["from-spec", "Door.tla"], dir);
1890
+ assert.notEqual(r.status, 0);
1891
+ assert.match(
1892
+ r.stderr,
1893
+ /timer\(\) must return a number of seconds above 0, or nothing; got "soon" for \w+/,
1894
+ );
1895
+ },
1896
+ );
1897
+
1898
+ const groupMap = `${doorMap}export const group = (v) =>
1899
+ ({ none: "Nothing to show", pending: "Waiting", shown: "Reply shown", lost: "Reply shown" })[v.reply];
1900
+ export const groupColors = { "Reply shown": "#d1f2eb", Waiting: null, "Never drawn": "#ffffff" };
1901
+ `;
1902
+
1903
+ test(
1904
+ "group() fills each state with its group's color, and the legend lists only the groups the chart draws",
1905
+ needsTlc,
1906
+ () => {
1907
+ const run = runDoor(groupMap)("NoLostReply");
1908
+ const lines = run.chart.split("\n");
1909
+ assert.deepEqual(lines.slice(0, lines.indexOf(' state "At the same time" as regions {')), [
1910
+ "---",
1911
+ "config:",
1912
+ " markdownAutoWrap: false",
1913
+ " state:",
1914
+ " useMaxWidth: false",
1915
+ "---",
1916
+ "stateDiagram-v2",
1917
+ " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631",
1918
+ " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c",
1919
+ " classDef group0 fill:#d1f2eb,stroke:#697976,color:#1b2631",
1920
+ " classDef group0Wrong fill:#d1f2eb,stroke:#c0392b,stroke-width:4px,color:#7b241c",
1921
+ " classDef group1 fill:#d6eaf8,stroke:#6b757c,color:#1b2631",
1922
+ " classDef group2 fill:#fdebd0,stroke:#7f7668,color:#1b2631",
1923
+ ' state "Legend" as legend {',
1924
+ ' state "Reply shown" as legendGroup0',
1925
+ ' state "Waiting" as legendGroup1',
1926
+ ' state "Nothing to show" as legendGroup2',
1927
+ ' state "Breaks a rule" as legendWrong',
1928
+ " }",
1929
+ " class legendGroup0 group0",
1930
+ " class legendGroup1 group1",
1931
+ " class legendGroup2 group2",
1932
+ " class legendWrong plainWrong",
1933
+ ]);
1934
+ assert.ok(lines.includes(' state "reply lost" as r1s1'));
1935
+ assert.deepEqual(
1936
+ lines.filter((l) => l.startsWith(" class r")),
1937
+ [
1938
+ " class r0s0 plain",
1939
+ " class r0s1 plain",
1940
+ " class r1s0 group2",
1941
+ " class r1s1 group0Wrong",
1942
+ " class r1s2 group1",
1943
+ " class r1s3 group0",
1944
+ ],
1945
+ );
1946
+ assert.match(run.stdout, /\n5 work, 1 breaks a rule\.\n/);
1947
+ const again = node(
1948
+ "compare-charts.mjs",
1949
+ ["tlc/regions-all.mmd", "tlc/regions-proof.mmd"],
1950
+ run.out,
1951
+ );
1952
+ assert.equal(again.stdout, "same\n", again.stderr);
1953
+
1954
+ const bug = run.bug.split("\n");
1955
+ assert.ok(
1956
+ bug.includes(
1957
+ " classDef group1Path fill:#d6eaf8,stroke:#1f3a93,stroke-width:4px,color:#1b2631",
1958
+ ),
1959
+ );
1960
+ assert.deepEqual(
1961
+ bug.filter((l) => /^ {2}class s\d/.test(l)),
1962
+ [
1963
+ " class s0 group0Wrong",
1964
+ " class s1 group1Path",
1965
+ " class s2 group2Path",
1966
+ " class s3 group1Path",
1967
+ ],
1968
+ );
1969
+
1970
+ const summary = JSON.parse(readFileSync(join(run.out, "summary.json"), "utf8"));
1971
+ assert.deepEqual(summary.chartGroups, [
1972
+ { name: "Reply shown", color: "#d1f2eb", states: 2 },
1973
+ { name: "Waiting", color: "#d6eaf8", states: 1 },
1974
+ { name: "Nothing to show", color: "#fdebd0", states: 1 },
1975
+ ]);
1976
+ assert.deepEqual(summary.mixedGroups, []);
1977
+ },
1978
+ );
1979
+
1980
+ test(
1981
+ "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",
1982
+ needsTlc,
1983
+ () => {
1984
+ const run = runDoor(groupMap)("NoLostReply");
1985
+ const tlcDir = join(run.out, "tlc");
1986
+ const chart = (map, ...extra) => {
1987
+ writeFileSync(join(tlcDir, "probe.map.mjs"), map);
1988
+ return node(
1989
+ "tlc-to-chart.mjs",
1990
+ [
1991
+ "--dot",
1992
+ "Door.dot",
1993
+ "--map",
1994
+ "probe.map.mjs",
1995
+ "--traces",
1996
+ "Door-NoLostReply.out",
1997
+ "--highlight",
1998
+ "0",
1999
+ ...extra,
2000
+ ],
2001
+ tlcDir,
2002
+ );
2003
+ };
2004
+
2005
+ const hidden = chart(
2006
+ `${doorMap}export const group = () => "All good";\nexport const groupColors = { "All good": "#e9f7ef" };\n`,
2007
+ "--regions",
2008
+ );
2009
+ assert.equal(hidden.status, 0, hidden.stderr);
2010
+ const lines = hidden.stdout.split("\n");
2011
+ assert.ok(
2012
+ lines.includes(
2013
+ " classDef group0Wrong fill:#e9f7ef,stroke:#c0392b,stroke-width:4px,color:#7b241c",
2014
+ ),
2015
+ );
2016
+ const legend = lines.indexOf(' state "Legend" as legend {');
2017
+ assert.deepEqual(lines.slice(legend, legend + 6), [
2018
+ ' state "Legend" as legend {',
2019
+ ' state "All good" as legendGroup0',
2020
+ ' state "Breaks a rule" as legendWrong',
2021
+ " }",
2022
+ " class legendGroup0 group0",
2023
+ " class legendWrong plainWrong",
2024
+ ]);
2025
+ assert.ok(lines.includes(" class r1s1 group0Wrong"));
2026
+ assert.equal(lines.filter((l) => / group0Wrong$/.test(l)).length, 1);
2027
+
2028
+ const coarse = chart(
2029
+ `const W = { Open: "Window open", Closed: "Window closed" };
2030
+ export const state = (v) => W[v.where];
2031
+ export const group = (v) => (v.reply === "shown" ? "Reply shown" : "Waiting");
2032
+ `,
2033
+ "--summary",
2034
+ "coarse.json",
2035
+ );
2036
+ assert.equal(coarse.status, 0, coarse.stderr);
2037
+ assert.deepEqual(
2038
+ coarse.stdout.split("\n").filter((l) => /^ {2}(class|classDef) /.test(l)),
2039
+ [
2040
+ " classDef plain fill:#f4f6f7,stroke:#566573,color:#1b2631",
2041
+ " classDef plainWrong fill:#f4f6f7,stroke:#c0392b,stroke-width:4px,color:#7b241c",
2042
+ " class legendWrong plainWrong",
2043
+ " class s0 plainWrong",
2044
+ " class s1 plain",
2045
+ ],
2046
+ );
2047
+ const summary = JSON.parse(readFileSync(join(tlcDir, "coarse.json"), "utf8"));
2048
+ assert.deepEqual(summary.chartGroups, []);
2049
+ assert.deepEqual(summary.mixedGroups, ["Window closed", "Window open"]);
2050
+
2051
+ for (const [map, error] of [
2052
+ [
2053
+ `export const group = (v) => (v.reply === "lost" ? "Breaks a rule" : "Fine");`,
2054
+ /group "Breaks a rule" has the name of a status; TLC decides the status, so name the group for what the reader sees/,
2055
+ ],
2056
+ [`export const group = () => "fixed";`, /group "fixed" has the name of a status/],
2057
+ [
2058
+ `export const group = () => "All good";\nexport const groupColors = { "All good": "#123" };`,
2059
+ /groupColors\["All good"\] "#123" is too dark for the status borders to show on it/,
2060
+ ],
2061
+ [
2062
+ `export const group = () => "All good";\nexport const groupColors = { "All good": "red" };`,
2063
+ /groupColors\["All good"\] "red" is not "#rgb" or "#rrggbb"/,
2064
+ ],
2065
+ [
2066
+ `export const group = () => "All good";\nexport const groupColors = ["#ffffff"];`,
2067
+ /groupColors must be an object from a group name to a color such as #d6eaf8/,
2068
+ ],
2069
+ [`export const group = () => 1;`, /group\(\) must return a group name or nothing; got 1/],
2070
+ ]) {
2071
+ const r = chart(`${doorMap}${map}\n`, "--regions");
2072
+ assert.equal(r.status, 1, map);
2073
+ assert.equal(r.stdout, "", map);
2074
+ assert.match(r.stderr, error);
2075
+ }
2076
+ },
2077
+ );
2078
+
2079
+ const order = (name, note, charge) => `---- MODULE ${name} ----
2080
+ EXTENDS Naturals
2081
+ VARIABLES screen, charges, note
2082
+ vars == <<screen, charges, note>>
2083
+ Init == screen = "Cart" /\\ charges = 0 /\\ note = 0
2084
+ Pay == screen = "Cart" /\\ screen' = "Paying" /\\ note' = ${note} /\\ UNCHANGED charges
2085
+ Charge == screen = "Paying" /\\ charges < 2 /\\ ${charge}
2086
+ Next == Pay \\/ Charge
2087
+ Spec == Init /\\ [][Next]_vars
2088
+ NoDoubleCharge == charges <= 1
2089
+ ====
2090
+ `;
2091
+ const chargeAgain = "charges' = charges + 1 /\\ UNCHANGED <<screen, note>>";
2092
+ const chargeOnce = `IF charges = 0 THEN charges' = 1 /\\ UNCHANGED <<screen, note>> ELSE screen' = "Paid" /\\ UNCHANGED <<charges, note>>`;
2093
+ const chargeFirstOnly = "charges = 0 /\\ charges' = 1 /\\ UNCHANGED <<screen, note>>";
2094
+
2095
+ test(
2096
+ "the fix changes a bug only at the first step that the reader sees differently",
2097
+ needsTlc,
2098
+ () => {
2099
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2100
+ writeFileSync(join(dir, "Order.tla"), order("Order", 0, chargeAgain));
2101
+ writeFileSync(join(dir, "Order.cfg"), "SPECIFICATION Spec\nINVARIANT NoDoubleCharge\n");
2102
+ const withFix = (charge, map = payMap) => {
2103
+ writeFileSync(join(dir, "Order.map.mjs"), map);
2104
+ writeFileSync(join(dir, "OrderFixed.tla"), order("OrderFixed", 1, charge));
2105
+ const r = node(
2106
+ "statechart.mjs",
2107
+ ["from-spec", "Order.tla", "--fixed", "OrderFixed.tla"],
2108
+ dir,
2109
+ );
2110
+ assert.equal(r.status, 1, r.stderr);
2111
+ const chart = readFileSync(join(dir, "Order-statechart", "chart.mmd"), "utf8");
2112
+ return {
2113
+ story: readFileSync(join(dir, "Order-statechart", "stories.md"), "utf8"),
2114
+ fixEdges: parseMermaid(chart)
2115
+ .regions.Main.transitions.filter((t) => t.label.includes("with the fix"))
2116
+ .map((t) => `${t.from} --> ${t.to} : ${t.label}`),
2117
+ };
2118
+ };
2119
+ const hidden = withFix(chargeAgain);
2120
+ assert.deepEqual(hidden.fixEdges, []);
2121
+ assert.match(
2122
+ hidden.story,
2123
+ /^With the fix you see the same screens and steps, so the fix does not change this path\.$/m,
2124
+ );
2125
+ const once = withFix(chargeOnce);
2126
+ assert.deepEqual(once.fixEdges, [
2127
+ "Paying, charged --> Paid, charged : with the fix, the bank charges you",
2128
+ ]);
2129
+ assert.match(
2130
+ once.story,
2131
+ /^With the fix, step 3 \(the bank charges you\) takes you to: Paid, charged\.$/m,
2132
+ );
2133
+ const unlabeledMap = payMap.replace(
2134
+ /^export const state = .*$/m,
2135
+ "export const state = (v) => v.screen;",
2136
+ );
2137
+ const unseen = withFix(chargeOnce, unlabeledMap);
2138
+ assert.deepEqual(unseen.fixEdges, ["Paying --> Paid : with the fix, the bank charges you"]);
2139
+ assert.match(
2140
+ unseen.story,
2141
+ /^With the fix, after step 1, "the bank charges you" happens and takes you to: Paid\.$/m,
2142
+ );
2143
+ const blocked = withFix(chargeFirstOnly, unlabeledMap);
2144
+ assert.deepEqual(blocked.fixEdges, []);
2145
+ assert.match(
2146
+ blocked.story,
2147
+ /yet the rule holds, so the state labels do not show what the fix changes/,
2148
+ );
2149
+ },
2150
+ );
2151
+
2152
+ const notify = (name, mutedArrive) => `---- MODULE ${name} ----
2153
+ EXTENDS Naturals
2154
+ VARIABLES screen, muted, banner, badge, missed, msgs, syncs
2155
+ vars == <<screen, muted, banner, badge, missed, msgs, syncs>>
2156
+ Init == screen = "Inbox" /\\ muted = FALSE /\\ banner = FALSE /\\ badge = FALSE
2157
+ /\\ missed = FALSE /\\ msgs = 0 /\\ syncs = 0
2158
+ OpenThread == screen = "Inbox" /\\ screen' = "Thread" /\\ UNCHANGED <<muted, banner, badge, missed, msgs, syncs>>
2159
+ OpenSettings == screen \\in {"Inbox", "Thread"} /\\ screen' = "Settings" /\\ UNCHANGED <<muted, banner, badge, missed, msgs, syncs>>
2160
+ TapBack == screen \\in {"Thread", "Settings"} /\\ screen' = "Inbox" /\\ UNCHANGED <<muted, banner, badge, missed, msgs, syncs>>
2161
+ Mute == screen = "Settings" /\\ ~muted /\\ muted' = TRUE /\\ UNCHANGED <<screen, banner, badge, missed, msgs, syncs>>
2162
+ Refresh == syncs < 2 /\\ syncs' = syncs + 1 /\\ UNCHANGED <<screen, muted, banner, badge, missed, msgs>>
2163
+ Arrive == msgs < syncs /\\ msgs' = msgs + 1
2164
+ /\\ (IF muted THEN ${mutedArrive} ELSE banner' = TRUE /\\ UNCHANGED <<badge, missed>>)
2165
+ /\\ UNCHANGED <<screen, muted, syncs>>
2166
+ Next == OpenThread \\/ OpenSettings \\/ TapBack \\/ Mute \\/ Refresh \\/ Arrive
2167
+ Spec == Init /\\ [][Next]_vars
2168
+ NeverMissed == ~missed
2169
+ NoBannerOnThread == ~(banner /\\ screen = "Thread")
2170
+ ====
2171
+ `;
2172
+
2173
+ test(
2174
+ "a step the reader does not see breaks the rule, and the fix shows it on another screen",
2175
+ needsTlc,
2176
+ () => {
2177
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2178
+ writeFileSync(
2179
+ join(dir, "Notify.tla"),
2180
+ notify("Notify", "missed' = TRUE /\\ UNCHANGED <<banner, badge>>"),
2181
+ );
2182
+ writeFileSync(
2183
+ join(dir, "NotifyBadge.tla"),
2184
+ notify("NotifyBadge", "badge' = TRUE /\\ UNCHANGED <<banner, missed>>"),
2185
+ );
2186
+ writeFileSync(
2187
+ join(dir, "Notify.cfg"),
2188
+ "SPECIFICATION Spec\nINVARIANT NeverMissed\nINVARIANT NoBannerOnThread\n",
2189
+ );
2190
+ writeFileSync(
2191
+ join(dir, "Notify.map.mjs"),
2192
+ `export const readerActions = ["OpenThread", "OpenSettings", "TapBack", "Mute", "Refresh"];
2193
+ export const state = (v) => [v.screen, v.muted && "muted", v.banner && "banner", v.badge && "badge"].filter(Boolean).join(", ");
2194
+ 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];
2195
+ export const cause = () => null;
2196
+ `,
2197
+ );
2198
+ const r = node(
2199
+ "statechart.mjs",
2200
+ ["from-spec", "Notify.tla", "--fixed", "NotifyBadge.tla", "--label", "the badge fix"],
2201
+ dir,
2202
+ );
2203
+ assert.equal(r.status, 1, r.stderr);
2204
+ const out = (f) => readFileSync(join(dir, "Notify-statechart", f), "utf8");
2205
+ const fixEdges = (f) =>
2206
+ parseMermaid(out(f))
2207
+ .regions.Main.transitions.filter((t) => t.label.includes("with the badge fix"))
2208
+ .map((t) => `${t.from} --> ${t.to} : ${t.label}`);
2209
+ const { classes } = JSON.parse(out("summary.json"));
2210
+ assert.deepEqual(
2211
+ classes.map((c) => [c.invariant, c.end, c.fix]),
2212
+ [
2213
+ ["NoBannerOnThread", "Thread, banner", "unchanged"],
2214
+ ["NeverMissed", "Settings, muted", "Settings, muted, badge"],
2215
+ ["NeverMissed", "Inbox, muted", "Inbox, muted, badge"],
2216
+ ["NeverMissed", "Settings, muted, banner", "Settings, muted, banner, badge"],
2217
+ ["NeverMissed", "Thread, muted", "Thread, muted, badge"],
2218
+ ["NoBannerOnThread", "Thread, muted, banner", "unchanged"],
2219
+ ["NeverMissed", "Inbox, muted, banner", "Inbox, muted, banner, badge"],
2220
+ ["NeverMissed", "Thread, muted, banner", "Thread, muted, banner, badge"],
2221
+ ["NoBannerOnThread", "Thread, muted, banner, badge", "new with the badge fix"],
2222
+ ],
2223
+ );
2224
+ const missed = classes.filter((c) => c.invariant === "NeverMissed");
2225
+
2226
+ const story = out("stories.md");
2227
+ assert.doesNotMatch(story, /do not show what/);
2228
+ assert.match(
2229
+ story,
2230
+ /^With the badge fix, after step 3, "a message arrives" happens and takes you to: Settings, muted, badge\.$/m,
2231
+ );
2232
+ const settings = classes.find((c) => c.end === "Settings, muted");
2233
+ assert.deepEqual(fixEdges(`bug-${settings.n}.mmd`), [
2234
+ "Settings, muted --> Settings, muted, badge : with the badge fix, a message arrives",
2235
+ ]);
2236
+ const drawn = fixEdges("chart.mmd");
2237
+ assert.equal(new Set(drawn).size, drawn.length);
2238
+ for (const c of missed)
2239
+ assert.ok(
2240
+ drawn.includes(`${c.end} --> ${c.end}, badge : with the badge fix, a message arrives`),
2241
+ `bug ${c.n}`,
2242
+ );
2243
+ },
2244
+ );
2245
+
2246
+ test(
2247
+ "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",
2248
+ needsTlc,
2249
+ () => {
2250
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2251
+ writeFileSync(
2252
+ join(dir, "Browse.tla"),
2253
+ `---- MODULE Browse ----
2254
+ EXTENDS Naturals
2255
+ VARIABLES screen, looks, scrolled
2256
+ vars == <<screen, looks, scrolled>>
2257
+ Init == screen = "Cart" /\\ looks = 0 /\\ scrolled = FALSE
2258
+ Look == screen = "Cart" /\\ looks < 3 /\\ looks' = looks + 1 /\\ UNCHANGED <<screen, scrolled>>
2259
+ Scroll == screen = "Cart" /\\ looks = 1 /\\ ~scrolled /\\ scrolled' = TRUE /\\ UNCHANGED <<screen, looks>>
2260
+ Pay == screen = "Cart" /\\ looks = 3 /\\ scrolled /\\ screen' = "Paying" /\\ UNCHANGED <<looks, scrolled>>
2261
+ Fail == screen = "Paying" /\\ screen' = "Failed" /\\ UNCHANGED <<looks, scrolled>>
2262
+ Hang == screen = "Paying" /\\ screen' = "Stuck" /\\ UNCHANGED <<looks, scrolled>>
2263
+ Next == Look \\/ Scroll \\/ Pay \\/ Fail \\/ Hang
2264
+ Spec == Init /\\ [][Next]_vars
2265
+ NeverLost == screen \\notin {"Failed", "Stuck"}
2266
+ ====
2267
+ `,
2268
+ );
2269
+ writeFileSync(join(dir, "Browse.cfg"), "SPECIFICATION Spec\nINVARIANT NeverLost\n");
2270
+ writeFileSync(
2271
+ join(dir, "Browse.map.mjs"),
2272
+ `export const readerActions = ["Look", "Scroll", "Pay"];
2273
+ export const state = (v) => v.screen;
2274
+ export const step = (a) => ({ Look: "you look", Scroll: "you scroll", Pay: "you pay", Fail: "the payment fails", Hang: "the payment hangs" })[a];
2275
+ export const cause = () => null;
2276
+ export const legend = { works: "Works on main", wrong: "Wrong on main" };
2277
+ `,
2278
+ );
2279
+ const r = node("statechart.mjs", ["from-spec", "Browse.tla"], dir);
2280
+ assert.equal(r.status, 1, r.stderr);
2281
+ const out = (f) => readFileSync(join(dir, "Browse-statechart", f), "utf8");
2282
+ assert.deepEqual(
2283
+ JSON.parse(out("summary.json")).classes.map((c) => [c.end, c.cause]),
2284
+ [
2285
+ ["Failed", null],
2286
+ ["Stuck", null],
2287
+ ],
2288
+ );
2289
+ assert.doesNotMatch(out("stories.md"), /^Why:|null/m);
2290
+ const loops = (chart) => [...chart.matchAll(/^ {2}(s\d+) --> \1 : (.*)$/gm)].map((m) => m[2]);
2291
+ assert.deepEqual(loops(out("bug-1.mmd")), ["1. you look<br/>2. you scroll<br/>3, 4. you look"]);
2292
+ assert.deepEqual(loops(out("chart.mmd")), ["you look or you scroll"]);
2293
+ const chart = out("chart.mmd");
2294
+ assert.ok(chart.includes('state "Works on main" as legendWorks'));
2295
+ assert.ok(chart.includes('state "Wrong on main" as legendWrong'));
2296
+ assert.doesNotMatch(chart, /legendFixed/);
2297
+ },
2298
+ );
2299
+
2300
+ test("a rule that stays broken reports only the step that broke it", needsTlc, () => {
2301
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2302
+ writeFileSync(
2303
+ join(dir, "Buzz.tla"),
2304
+ `---- MODULE Buzz ----
2305
+ EXTENDS Naturals
2306
+ VARIABLES x, y, buzzed
2307
+ vars == <<x, y, buzzed>>
2308
+ Init == x = 0 /\\ y = 0 /\\ buzzed = FALSE
2309
+ IncX == x < 6 /\\ x' = x + 1 /\\ UNCHANGED <<y, buzzed>>
2310
+ IncY == y < 6 /\\ y' = y + 1 /\\ UNCHANGED <<x, buzzed>>
2311
+ Ring == x = 2 /\\ ~buzzed /\\ buzzed' = TRUE /\\ UNCHANGED <<x, y>>
2312
+ Next == IncX \\/ IncY \\/ Ring
2313
+ Spec == Init /\\ [][Next]_vars
2314
+ NeverBuzzed == ~buzzed
2315
+ ====
2316
+ `,
2317
+ );
2318
+ writeFileSync(join(dir, "Buzz.cfg"), "SPECIFICATION Spec\nINVARIANT NeverBuzzed\n");
2319
+ writeFileSync(
2320
+ join(dir, "Buzz.map.mjs"),
2321
+ 'export const state = (v) => (v.buzzed ? "Buzzed" : "Quiet");\nexport const step = () => "time passes";\n',
2322
+ );
2323
+ const r = node("statechart.mjs", ["from-spec", "Buzz.tla"], dir);
2324
+ assert.equal(r.status, 1, r.stderr);
2325
+ const traces = parseTraces(
2326
+ readFileSync(join(dir, "Buzz-statechart", "tlc", "Buzz-NeverBuzzed.out"), "utf8"),
2327
+ );
2328
+ assert.equal(traces.length, 7);
2329
+ for (const t of traces)
2330
+ assert.equal(
2331
+ t.steps.findIndex((s) => s.vars.buzzed),
2332
+ t.steps.length - 1,
2333
+ );
2334
+ });
2335
+
2336
+ test("from-spec warns when steps carry the name of the whole next-state action", needsTlc, () => {
2337
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2338
+ const count = (next) => `---- MODULE Count ----
2339
+ EXTENDS Naturals
2340
+ VARIABLES x
2341
+ Init == x = 0
2342
+ Inc == x < 3 /\\ x' = x + 1
2343
+ ${next}
2344
+ Spec == \\* not [][Inc]_x
2345
+ Init /\\ [][Next]_x
2346
+ ====
2347
+ `;
2348
+ writeFileSync(join(dir, "Count.cfg"), "SPECIFICATION Spec\n");
2349
+ writeFileSync(join(dir, "Count.map.mjs"), "export const state = (v) => String(v.x);\n");
2350
+ writeFileSync(join(dir, "Count.tla"), count("Next == Inc \\/ (x = 3 /\\ x' = 0)"));
2351
+ const unnamed = node("statechart.mjs", ["from-spec", "Count.tla"], dir);
2352
+ assert.equal(unnamed.status, 0, unnamed.stderr);
2353
+ assert.match(
2354
+ unnamed.stderr,
2355
+ /^Count\.tla: 1 step has the name Next, the whole next-state action/,
2356
+ );
2357
+ writeFileSync(join(dir, "Count.tla"), count("Next == Inc"));
2358
+ const named = node("statechart.mjs", ["from-spec", "Count.tla"], dir);
2359
+ assert.equal(named.status, 0, named.stderr);
2360
+ assert.equal(named.stderr, "");
2361
+ });
2362
+
2363
+ const writeLamp = (dir) => {
2364
+ writeFileSync(
2365
+ join(dir, "Lamp.tla"),
2366
+ "---- MODULE Lamp ----\nVARIABLE on\nInit == on = FALSE\nOn == on' = TRUE\nOff == on' = FALSE\nSpec == Init /\\ [][On \\/ Off]_on\n====\n",
2367
+ );
2368
+ writeFileSync(join(dir, "Lamp.cfg"), "SPECIFICATION Spec\n");
2369
+ writeFileSync(
2370
+ join(dir, "Lamp.map.mjs"),
2371
+ 'export const state = (v) => (v.on ? "On" : "Off");\nexport const step = () => "you flip the switch";\n',
2372
+ );
2373
+ return join(dir, "Lamp-statechart");
2374
+ };
2375
+
2376
+ test("a rerun without --png removes the chart.png of an earlier run", needsTlc, () => {
2377
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2378
+ const out = writeLamp(dir);
2379
+ mkdirSync(out);
2380
+ writeFileSync(join(out, "chart.png"), "");
2381
+ const r = node("statechart.mjs", ["from-spec", "Lamp.tla"], dir);
2382
+ assert.equal(r.status, 0, r.stderr);
2383
+ assert.deepEqual(
2384
+ readdirSync(out).filter((f) => f.endsWith(".png")),
2385
+ [],
2386
+ );
2387
+ });
2388
+
2389
+ const writeSlowPay = (dir, ticks, paid = "Paid") => {
2390
+ writeFileSync(
2391
+ join(dir, "Pay.tla"),
2392
+ `---- MODULE Pay ----
2393
+ EXTENDS Naturals
2394
+ VARIABLES screen, charges, ticks
2395
+ vars == <<screen, charges, ticks>>
2396
+ Init == screen = "Cart" /\\ charges = 0 /\\ ticks = 0
2397
+ TapPay == screen = "Cart" /\\ screen' = "Paying" /\\ UNCHANGED <<charges, ticks>>
2398
+ Charge == screen = "Paying" /\\ charges < 2 /\\ charges' = charges + 1 /\\ UNCHANGED <<screen, ticks>>
2399
+ Done == screen = "Paying" /\\ charges > 0 /\\ screen' = "${paid}" /\\ UNCHANGED <<charges, ticks>>
2400
+ Tick == ticks < ${ticks} /\\ ticks' = ticks + 1 /\\ UNCHANGED <<screen, charges>>
2401
+ Spec == Init /\\ [][TapPay \\/ Charge \\/ Done \\/ Tick]_vars
2402
+ ChargedAtMostTwice == charges <= 2
2403
+ ====
2404
+ `,
2405
+ );
2406
+ writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\nINVARIANT ChargedAtMostTwice\n");
2407
+ writeFileSync(
2408
+ join(dir, "Pay.map.mjs"),
2409
+ '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',
2410
+ );
2411
+ return join(dir, "Pay-statechart");
2412
+ };
2413
+ const startRun = (dir, { wrap = [], env = {}, args = ["from-spec", "Pay.tla"] } = {}) => {
2414
+ const [command, ...rest] = [...wrap, process.execPath, join(scripts, "statechart.mjs"), ...args];
2415
+ const child = spawn(command, rest, {
2416
+ cwd: dir,
2417
+ env: { ...process.env, ...env },
2418
+ stdio: ["ignore", "ignore", "pipe"],
2419
+ detached: true,
2420
+ });
2421
+ let stderr = "";
2422
+ child.stderr.on("data", (d) => {
2423
+ stderr += d;
2424
+ });
2425
+ return {
2426
+ child,
2427
+ done: new Promise((done) => child.on("close", (status) => done({ status, stderr }))),
2428
+ };
2429
+ };
2430
+ const until = async (ready) => {
2431
+ while (!ready()) await sleep(20);
2432
+ };
2433
+ const ownTmp = (dir, name) => {
2434
+ const tmp = join(dir, name);
2435
+ mkdirSync(tmp);
2436
+ return tmp;
2437
+ };
2438
+ const tlcStarted = (tmp) => readdirSync(tmp).some((f) => f.startsWith("tlc-"));
2439
+ const staged = (dir) => readdirSync(dir).filter((f) => f.startsWith("."));
2440
+
2441
+ test(
2442
+ "a run whose Node is killed mid-TLC leaves the output folder as it was, and a rerun charts its own spec",
2443
+ needsTlc,
2444
+ async () => {
2445
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2446
+ const out = writeSlowPay(dir, 60000);
2447
+ const tmp = ownTmp(dir, "tmp");
2448
+ const first = startRun(dir, { env: { TMPDIR: tmp } });
2449
+ await until(() => tlcStarted(tmp));
2450
+ await sleep(300);
2451
+ first.child.kill("SIGTERM");
2452
+ await first.done;
2453
+ assert.doesNotThrow(() => process.kill(-first.child.pid, 0), "TLC outlives its Node");
2454
+ assert.equal(existsSync(out), false);
2455
+
2456
+ writeSlowPay(dir, 200, "Receipt");
2457
+ const second = node("statechart.mjs", ["from-spec", "Pay.tla"], dir);
2458
+ process.kill(-first.child.pid, "SIGKILL");
2459
+ assert.equal(second.status, 0, second.stderr);
2460
+ for (const file of ["chart.mmd", join("tlc", "Pay.dot")]) {
2461
+ const text = readFileSync(join(out, file), "utf8");
2462
+ assert.match(text, /Receipt/);
2463
+ assert.doesNotMatch(text, /Paid/);
2464
+ }
2465
+ assert.deepEqual(staged(dir), []);
2466
+ },
2467
+ );
2468
+
2469
+ const changedMidRun = async ({ wrap = [], env = {} }) => {
2470
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2471
+ const out = writeSlowPay(dir, 20000);
2472
+ const tmp = ownTmp(dir, "tmp-a");
2473
+ const first = startRun(dir, { wrap, env: { ...env, TMPDIR: tmp } });
2474
+ await until(() => tlcStarted(tmp));
2475
+ await sleep(500);
2476
+ writeSlowPay(dir, 20000, "Receipt");
2477
+ const second = startRun(dir, { wrap, env: { ...env, TMPDIR: ownTmp(dir, "tmp-b") } });
2478
+ const [paid, receipt] = await Promise.all([first.done, second.done]);
2479
+ assert.equal(receipt.status, 0, receipt.stderr);
2480
+ assert.equal(paid.status, 2, paid.stderr);
2481
+ assert.equal(
2482
+ paid.stderr,
2483
+ "Pay.tla changed while this run read it, so the run left Pay-statechart as it was. Run the command again.\n",
2484
+ );
2485
+ const chart = readFileSync(join(out, "chart.mmd"), "utf8");
2486
+ assert.match(chart, /Receipt/);
2487
+ assert.doesNotMatch(chart, /Paid/);
2488
+ assert.deepEqual(staged(dir), []);
2489
+ };
2490
+
2491
+ test(
2492
+ "a run whose spec changes while it reads it leaves the output folder, and the run that read the new spec charts it",
2493
+ needsTlc,
2494
+ () => changedMidRun({}),
2495
+ );
2496
+
2497
+ const unshare = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"];
2498
+ const hidePidNamespace = `#define _GNU_SOURCE
2499
+ #include <dlfcn.h>
2500
+ #include <errno.h>
2501
+ #include <string.h>
2502
+ #include <unistd.h>
2503
+ ssize_t readlink(const char *path, char *buf, size_t size) {
2504
+ static ssize_t (*real)(const char *, char *, size_t);
2505
+ if (!real) real = dlsym(RTLD_NEXT, "readlink");
2506
+ if (path && strcmp(path, "/proc/self/ns/pid") == 0) { errno = EIO; return -1; }
2507
+ return real(path, buf, size);
2508
+ }
2509
+ `;
2510
+ const hidingPreload = (() => {
2511
+ if (spawnSync(unshare[0], [...unshare.slice(1), "true"]).status !== 0) return null;
2512
+ const dir = mkdtempSync(join(tmpdir(), "statechart-preload-"));
2513
+ writeFileSync(join(dir, "hide.c"), hidePidNamespace);
2514
+ const so = join(dir, "hide.so");
2515
+ const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "hide.c"), "-ldl"]);
2516
+ return cc.status === 0 ? so : null;
2517
+ })();
2518
+ test(
2519
+ "runs in PID namespaces that hide which namespace they are in chart only their own spec",
2520
+ {
2521
+ skip:
2522
+ needsTlc.skip ||
2523
+ (!hidingPreload && "cannot make a PID namespace, or cannot compile a readlink preload"),
2524
+ },
2525
+ () => changedMidRun({ wrap: unshare, env: { LD_PRELOAD: hidingPreload } }),
2526
+ );
2527
+
2528
+ test(
2529
+ "a file that is put in the output folder during a run stays, and the run exits 2",
2530
+ needsTlc,
2531
+ async () => {
2532
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2533
+ const out = writeSlowPay(dir, 20000);
2534
+ const tmp = ownTmp(dir, "tmp");
2535
+ const run = startRun(dir, { env: { TMPDIR: tmp } });
2536
+ await until(() => tlcStarted(tmp));
2537
+ mkdirSync(out);
2538
+ writeFileSync(join(out, "notes.md"), "mine\n");
2539
+ const r = await run.done;
2540
+ assert.equal(r.status, 2);
2541
+ assert.equal(
2542
+ r.stderr,
2543
+ "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",
2544
+ );
2545
+ assert.deepEqual(readdirSync(out), ["notes.md"]);
2546
+ assert.deepEqual(staged(dir), []);
2547
+ },
2548
+ );
2549
+
2550
+ const failRenames = `#define _GNU_SOURCE
2551
+ #include <dlfcn.h>
2552
+ #include <errno.h>
2553
+ #include <stdlib.h>
2554
+ #include <string.h>
2555
+ static size_t hits;
2556
+ int rename(const char *from, const char *to) {
2557
+ static int (*real)(const char *, const char *);
2558
+ if (!real) real = dlsym(RTLD_NEXT, "rename");
2559
+ const char *base = strrchr(to, '/'), *fails = getenv("RENAME_FAILS");
2560
+ base = base ? base + 1 : to;
2561
+ if (fails && strcmp(base, "shared") == 0 && hits < strlen(fails)) {
2562
+ errno = fails[hits++] == 'N' ? ENOTEMPTY : EIO;
2563
+ return -1;
2564
+ }
2565
+ return real(from, to);
2566
+ }
2567
+ `;
2568
+ const failingRenames = (() => {
2569
+ const dir = mkdtempSync(join(tmpdir(), "statechart-preload-"));
2570
+ writeFileSync(join(dir, "rename.c"), failRenames);
2571
+ const so = join(dir, "rename.so");
2572
+ const cc = spawnSync("cc", ["-shared", "-fPIC", "-o", so, join(dir, "rename.c"), "-ldl"]);
2573
+ return cc.status === 0 ? so : null;
2574
+ })();
2575
+ test(
2576
+ "a run whose rename fails after it moves the output folder aside puts the folder back, or says where it is",
2577
+ { skip: needsTlc.skip || (!failingRenames && "cannot compile a rename preload") },
2578
+ () => {
2579
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2580
+ writeLamp(dir);
2581
+ const args = ["from-spec", "Lamp.tla", "--out", "shared"];
2582
+ const shared = join(dir, "shared");
2583
+ const first = node("statechart.mjs", args, dir);
2584
+ assert.equal(first.status, 0, first.stderr);
2585
+ const chart = readFileSync(join(shared, "chart.mmd"), "utf8");
2586
+ const run = (fails) =>
2587
+ node("statechart.mjs", args, dir, { LD_PRELOAD: failingRenames, RENAME_FAILS: fails });
2588
+
2589
+ const back = run("NI");
2590
+ assert.equal(back.status, 2, back.stderr);
2591
+ assert.match(
2592
+ back.stderr,
2593
+ /^cannot replace shared: EIO: .*\. The run left shared as it was\.\n$/,
2594
+ );
2595
+ assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), chart);
2596
+ assert.deepEqual(staged(dir), []);
2597
+
2598
+ const stuck = run("NII");
2599
+ assert.equal(stuck.status, 2, stuck.stderr);
2600
+ const named = stuck.stderr.match(
2601
+ /^cannot replace shared: EIO: .*\. This run moved the previous shared to (\.shared-\w+-old) and could not put it back\.\n$/,
2602
+ );
2603
+ assert.ok(named, stuck.stderr);
2604
+ const aside = named[1];
2605
+ assert.equal(existsSync(shared), false);
2606
+ assert.equal(readFileSync(join(dir, aside, "chart.mmd"), "utf8"), chart);
2607
+ assert.deepEqual(staged(dir), [aside]);
2608
+ },
2609
+ );
2610
+
2611
+ test(
2612
+ "each TLC run writes the standard modules in its own temp folder, so overlapping runs cannot rewrite them under each other",
2613
+ needsTlc,
2614
+ () => {
2615
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2616
+ writeSlowPay(dir, 0);
2617
+ const tmp = ownTmp(dir, "tmp");
2618
+ const log = join(dir, "java.log");
2619
+ const java = join(dir, "java");
2620
+ writeFileSync(
2621
+ java,
2622
+ `#!/bin/sh
2623
+ for a; do case "$a" in -Djava.io.tmpdir=*) t="\${a#-Djava.io.tmpdir=}";; esac; done
2624
+ "$REAL_JAVA" "$@" > "$LOG.$$"; s=$?
2625
+ cat "$LOG.$$"
2626
+ echo "$t: $(grep 'Naturals.tla' "$LOG.$$")" >> "$LOG"
2627
+ rm "$LOG.$$"
2628
+ exit $s
2629
+ `,
2630
+ { mode: 0o755 },
2631
+ );
2632
+ const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, {
2633
+ JAVA: java,
2634
+ TLA2TOOLS: tools.jar,
2635
+ REAL_JAVA: tools.java,
2636
+ LOG: log,
2637
+ TMPDIR: tmp,
2638
+ });
2639
+ assert.equal(r.status, 0, r.stderr);
2640
+ const runs = readFileSync(log, "utf8").trim().split("\n");
2641
+ assert.ok(runs.length > 0);
2642
+ const folders = new Set();
2643
+ for (const line of runs) {
2644
+ const [folder, parsed] = line.split(": ");
2645
+ assert.ok(folder.startsWith(join(tmp, "tlc-")), line);
2646
+ assert.equal(parsed, `Parsing file ${join(folder, "Naturals.tla")}`);
2647
+ folders.add(folder);
2648
+ }
2649
+ assert.equal(folders.size, runs.length);
2650
+ },
2651
+ );
2652
+
2653
+ test(
2654
+ "a run that TLC stops on an error keeps TLC's output and writes no output folder",
2655
+ needsTlc,
2656
+ () => {
2657
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2658
+ writeFileSync(
2659
+ join(dir, "Pay.tla"),
2660
+ "---- MODULE Pay ----\nVARIABLE x\nASSUME FALSE\nInit == x = 0\nSpec == Init /\\ [][x' = x]_x\n====\n",
2661
+ );
2662
+ writeFileSync(join(dir, "Pay.cfg"), "SPECIFICATION Spec\n");
2663
+ writeFileSync(join(dir, "Pay.map.mjs"), 'export const state = () => "Cart";\n');
2664
+ const tmp = ownTmp(dir, "tmp");
2665
+ const r = node("statechart.mjs", ["from-spec", "Pay.tla"], dir, { TMPDIR: tmp });
2666
+ assert.equal(r.status, 2);
2667
+ const [, file] = r.stderr.match(
2668
+ /^(\S+\/tlc\/Pay\.out): TLC stopped on an error that is not a broken rule: Assumption .* is false\.\n$/,
2669
+ );
2670
+ assert.ok(file.startsWith(tmp), file);
2671
+ assert.match(readFileSync(file, "utf8"), /Assumption .* is false/);
2672
+ assert.equal(existsSync(join(dir, "Pay-statechart")), false);
2673
+ },
2674
+ );
2675
+
2676
+ test(
2677
+ "runs of two specs that overlap on one output folder all finish, and the folder always holds one run's files",
2678
+ needsTlc,
2679
+ async () => {
2680
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2681
+ const out = writeSlowPay(dir, 400);
2682
+ writeLamp(dir);
2683
+ const pay = ["from-spec", "Pay.tla", "--out", "shared"];
2684
+ const lamp = ["from-spec", "Lamp.tla", "--out", "shared"];
2685
+ const alone = {};
2686
+ for (const args of [pay, lamp]) {
2687
+ const solo = join(dir, `${args[1]}-alone`);
2688
+ const r = node("statechart.mjs", [...args.slice(0, 2), "--out", solo], dir);
2689
+ assert.equal(r.status, 0, r.stderr);
2690
+ alone[args[1]] = {
2691
+ top: readdirSync(solo).sort().join(),
2692
+ tlc: readdirSync(join(solo, "tlc")).sort().join(),
2693
+ chart: readFileSync(join(solo, "chart.mmd"), "utf8"),
2694
+ summary: readFileSync(join(solo, "summary.json"), "utf8"),
2695
+ };
2696
+ }
2697
+ const shared = join(dir, "shared");
2698
+ const seen = new Set();
2699
+ let watching = true;
2700
+ const watch = (async () => {
2701
+ while (watching) {
2702
+ try {
2703
+ seen.add(readdirSync(join(shared, "tlc")).sort().join());
2704
+ } catch (e) {
2705
+ if (e.code !== "ENOENT") throw e;
2706
+ }
2707
+ await sleep(5);
2708
+ }
2709
+ })();
2710
+ const runs = [];
2711
+ for (let i = 0; i < 6; i++) {
2712
+ runs.push(startRun(dir, { args: i % 2 ? lamp : pay }).done);
2713
+ await sleep(150);
2714
+ }
2715
+ const finished = await Promise.all(runs);
2716
+ watching = false;
2717
+ await watch;
2718
+ for (const r of finished) assert.equal(r.status, 0, r.stderr);
2719
+ const runsOf = new Map(Object.entries(alone).map(([spec, a]) => [a.tlc, spec]));
2720
+ for (const listing of seen) assert.ok(runsOf.has(listing), `tlc/ had ${listing}`);
2721
+ const last = alone[runsOf.get(readdirSync(join(shared, "tlc")).sort().join())];
2722
+ assert.equal(readdirSync(shared).sort().join(), last.top);
2723
+ assert.equal(readFileSync(join(shared, "chart.mmd"), "utf8"), last.chart);
2724
+ assert.equal(readFileSync(join(shared, "summary.json"), "utf8"), last.summary);
2725
+ assert.equal(existsSync(out), false);
2726
+ assert.deepEqual(staged(dir), []);
2727
+ },
2728
+ );
2729
+
2730
+ test(
2731
+ "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",
2732
+ needsTlc,
2733
+ () => {
2734
+ const dir = mkdtempSync(join(tmpdir(), "statechart-"));
2735
+ const model = (ext) => readFileSync(join(scripts, "model", `OutputFolder${ext}`), "utf8");
2736
+ const r = node(
2737
+ "statechart.mjs",
2738
+ ["from-spec", join(scripts, "model", "OutputFolder.tla"), "--out", join(dir, "out")],
2739
+ dir,
2740
+ );
2741
+ assert.equal(r.status, 0, r.stdout + r.stderr);
2742
+ assert.match(r.stdout, /\nNo rule is broken\.\n/);
2743
+ for (const [i, [rule, guard, without, story]] of [
2744
+ [
2745
+ "ChartsOneSpec",
2746
+ "/\\ read' = Set(read, r, read[r] \\cup {spec})",
2747
+ "/\\ read' = [q \\in Runs |-> read[q] \\cup {spec}]",
2748
+ /You start run 2 in a second shell\..*\n.*Run 2 moves its folder into place\. Now: Two runs going: chart of both specs/,
2749
+ ],
2750
+ [
2751
+ "ChartsOneSpec",
2752
+ '/\\ pc[r] = "publish" /\\ spec = snap[r] /\\ ~out.notes',
2753
+ '/\\ pc[r] = "publish" /\\ ~out.notes',
2754
+ /You edit the spec\..*\n.*Run 1 moves its folder into place\. Now: One run going: chart of both specs/,
2755
+ ],
2756
+ [
2757
+ "KeepsYourFiles",
2758
+ '/\\ pc[r] = "aside" /\\ ~aside[r].notes\n',
2759
+ '/\\ pc[r] = "aside"\n',
2760
+ /Run 1 deletes the old folder and finishes\. Now: Done: chart of the spec\n/,
2761
+ ],
2762
+ [
2763
+ "NeverLosesChart",
2764
+ " /\\ \\/ /\\ aside[r].exists /\\ ~out.exists\n /\\ out' = aside[r]\n /\\ aside' = Set(aside, r, NoFolder)\n \\/ UNCHANGED <<out, aside>>\n",
2765
+ " /\\ out' = out\n /\\ aside' = Set(aside, r, IF aside[r].notes THEN aside[r] ELSE NoFolder)\n",
2766
+ /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/,
2767
+ ],
2768
+ [
2769
+ "NeverLosesChart",
2770
+ '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists /\\ ~out.notes',
2771
+ '/\\ pc[r] = "swap" /\\ out.exists /\\ aside[r].exists',
2772
+ /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/,
2773
+ ],
2774
+ ].entries()) {
2775
+ const mutant = join(dir, `${i}-${rule}`);
2776
+ mkdirSync(mutant);
2777
+ assert.ok(model(".tla").includes(guard), guard);
2778
+ writeFileSync(join(mutant, "OutputFolder.tla"), model(".tla").replace(guard, without));
2779
+ for (const ext of [".cfg", ".map.mjs"])
2780
+ writeFileSync(join(mutant, `OutputFolder${ext}`), model(ext));
2781
+ const m = node(
2782
+ "statechart.mjs",
2783
+ ["from-spec", "OutputFolder.tla", "--expect-broken", rule],
2784
+ mutant,
2785
+ );
2786
+ assert.equal(m.status, 0, m.stdout + m.stderr);
2787
+ assert.match(m.stdout, story);
2788
+ }
2789
+ },
2790
+ );