From 6f45cd150691d8ac15e3d1f1e8f9f5d4214d89b7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 21:59:22 -0400 Subject: [PATCH] fix(pstack): read multi-line Mermaid notes in statechart charts --- .../skills/statechart/scripts/lib.mjs | 10 ++++++++++ .../statechart/scripts/test/statechart.test.mjs | 17 +++++++++++++++++ pstack/skills/statechart/scripts/lib.mjs | 10 ++++++++++ .../statechart/scripts/test/statechart.test.mjs | 17 +++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs index 238f1745e..ec49ed6d2 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/lib.mjs @@ -567,7 +567,16 @@ export function parseMermaid(text) { implicit.set(id, part()); }; const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, ""); + let inNote = false; for (const raw of body.split("\n")) { + if (inNote) { + inNote = !/^\s*end\s+note\s*$/.test(raw); + continue; + } + if (/^\s*note\s[^:]*$/.test(raw)) { + inNote = true; + continue; + } const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); if (when) { @@ -644,6 +653,7 @@ export function parseMermaid(text) { } throw new Error(`cannot read chart line: ${line}`); } + if (inNote) throw new Error("the chart has a note with no end note"); if (stack.length !== 1) throw new Error("the chart has an unclosed {"); if (root.parts.length > 1) throw new Error( diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs index 543fe8d67..e417e7b30 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/statechart.test.mjs @@ -526,6 +526,23 @@ FromBase == Base!Safe ); }); +test("a multi-line note is not part of the machine", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + [*] --> A + note right of A + Waits for the user. + A --> C : not a step + end note + A --> B : go + note left of B : done`); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); + assert.deepEqual(edges(regions.Main), ["A -> B : go"]); + assert.throws( + () => parseMermaid("stateDiagram-v2\n [*] --> A\n note right of A\n A --> B : go"), + /note with no end note/, + ); +}); + test("the legend is not part of the machine", () => { const { regions } = parseMermaid(`stateDiagram-v2 state "Legend" as legend { diff --git a/pstack/skills/statechart/scripts/lib.mjs b/pstack/skills/statechart/scripts/lib.mjs index 238f1745e..ec49ed6d2 100644 --- a/pstack/skills/statechart/scripts/lib.mjs +++ b/pstack/skills/statechart/scripts/lib.mjs @@ -567,7 +567,16 @@ export function parseMermaid(text) { implicit.set(id, part()); }; const body = text.replace(/^\s*---[ \t]*\r?\n[^]*?\r?\n---[ \t]*(\r?\n|$)/, ""); + let inNote = false; for (const raw of body.split("\n")) { + if (inNote) { + inNote = !/^\s*end\s+note\s*$/.test(raw); + continue; + } + if (/^\s*note\s[^:]*$/.test(raw)) { + inNote = true; + continue; + } const line = raw.trim().replace(/(\[\*\]|[A-Za-z_]\w*):::[\w-]+/g, "$1"); const when = /^%%\s*when\s+(not\s+)?(.+)$/.exec(line); if (when) { @@ -644,6 +653,7 @@ export function parseMermaid(text) { } throw new Error(`cannot read chart line: ${line}`); } + if (inNote) throw new Error("the chart has a note with no end note"); if (stack.length !== 1) throw new Error("the chart has an unclosed {"); if (root.parts.length > 1) throw new Error( diff --git a/pstack/skills/statechart/scripts/test/statechart.test.mjs b/pstack/skills/statechart/scripts/test/statechart.test.mjs index 543fe8d67..e417e7b30 100644 --- a/pstack/skills/statechart/scripts/test/statechart.test.mjs +++ b/pstack/skills/statechart/scripts/test/statechart.test.mjs @@ -526,6 +526,23 @@ FromBase == Base!Safe ); }); +test("a multi-line note is not part of the machine", () => { + const { regions } = parseMermaid(`stateDiagram-v2 + [*] --> A + note right of A + Waits for the user. + A --> C : not a step + end note + A --> B : go + note left of B : done`); + assert.deepEqual([...regions.Main.states].sort(), ["A", "B"]); + assert.deepEqual(edges(regions.Main), ["A -> B : go"]); + assert.throws( + () => parseMermaid("stateDiagram-v2\n [*] --> A\n note right of A\n A --> B : go"), + /note with no end note/, + ); +}); + test("the legend is not part of the machine", () => { const { regions } = parseMermaid(`stateDiagram-v2 state "Legend" as legend {