From 66118292ae9cc1bf5c528c42927009644ab86f60 Mon Sep 17 00:00:00 2001 From: Ben Jones Date: Fri, 9 Oct 2026 11:17:12 -0700 Subject: [PATCH] diagram: replace `;` in sequenceDiagram message and note text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A semicolon separates statements in Mermaid, and sequence message text is unquoted — so `A->>B: one thing; another` parses as two statements and the block fails to render, with the second half's first word read as an actor. Scoped to sequence blocks. A flowchart label is quoted, so a `;` there is safe; rewriting it would change prose to fix a problem that grammar does not have. An em dash rather than `;`: the entity does make the block parse, but it renders literally as `&;`, which is readable-but-wrong and worse than a clear substitution. A semicolon in this prose always joins two clauses. 7 tests, including that a clean block is returned byte-identical, that a colon inside the text is not treated as the boundary, and that a participant declaration is not mistaken for a message. Co-Authored-By: Claude Opus 5 (1M context) --- spec_eval/authoring.py | 40 ++++++++++++++++++++++++ tests/contract/test_diagram.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/spec_eval/authoring.py b/spec_eval/authoring.py index 62255e4..3b32474 100644 --- a/spec_eval/authoring.py +++ b/spec_eval/authoring.py @@ -324,6 +324,44 @@ def _document_or_none(md, note): _KEYWORD_LINE_RE = re.compile(r"^\s*(?:end|subgraph|direction|classDef|linkStyle|click)\b") +_SEQUENCE_BLOCK_RE = re.compile(r"```mermaid\n(\s*sequenceDiagram\b.*?)```", re.S) +# A message or a note: everything after the first `:` is free text, and that grammar has no quoting, +# so a `;` there is read as a STATEMENT SEPARATOR and splits the line in two. +_SEQ_TEXT_RE = re.compile(r"^(\s*(?:(?:Note|note)\s+(?:over|left of|right of)\s+[^:]+|[^:\n]*?" + r"(?:-->>|->>|-->|->|--x|-x|--\)|-\))[^:\n]*?)\s*:)(.*)$") + + +def escape_sequence_text(md): + """Replace `;` in sequenceDiagram message and note text. Returns `(markdown, fixed)`. + + A semicolon is a STATEMENT SEPARATOR in Mermaid, and a sequence diagram's message text is + unquoted, so `A->>B: one thing; another` parses as two statements and the block fails. The + second half becomes a new statement whose first word is read as an actor. + + Flowchart labels are unaffected because they are quoted, so this is scoped to sequence blocks. + + **Why an em dash and not an entity:** `;` does make the block parse, but it renders + literally as `&;` — the diagram is then readable-but-wrong, which is worse than a clear + substitution. A semicolon in this prose always joins two clauses, and an em dash joins them + correctly.""" + fixed = 0 + + def _fix_block(m): + nonlocal fixed + out = [] + for line in m.group(1).split("\n"): + hit = _SEQ_TEXT_RE.match(line) + if hit and ";" in hit.group(2): + text = re.sub(r"\s*;\s*", " \u2014 ", hit.group(2)).rstrip() + out.append(hit.group(1) + text) + fixed += 1 + else: + out.append(line) + return "```mermaid\n" + "\n".join(out) + "```" + + return _SEQUENCE_BLOCK_RE.sub(_fix_block, md or ""), fixed + + def _sub_outside_quotes(pattern, repl, line): """Substitute only outside double-quoted spans. @@ -675,6 +713,7 @@ def _repo_overview(): return None, guard_note md, downgraded = verify_scanner_labels(md, ep) md, _renamed = rename_reserved_node_ids(md) + md, _escaped = escape_sequence_text(md) md, fixed, broken = repair_links(md, repo, "OVERVIEW.md") if syscontext.evidence_block(ctx): # stamp the fingerprint this System context section was md = md.rstrip() + "\n\n" + syscontext.stamp_comment(ctx) # rendered from, so `--check` can @@ -784,6 +823,7 @@ def diagram_block(repo, config, model, on_progress=None): single_pass=True) # one picture, drawn once, over every module md, downgraded = verify_scanner_labels(md, ep) # the diagram's own provenance claims, held to the scan md, _renamed = rename_reserved_node_ids(md) # a keyword as a node id breaks the whole block + md, _escaped = escape_sequence_text(md) # a `;` in message text splits the statement sliced = len(_pack(items, reduce_cap)) > 1 # the same packing the pass just force-fitted note = _cap_note( f"{len(items)} module intents exceeded the reduce cap — each was sliced to an equal share for the " diff --git a/tests/contract/test_diagram.py b/tests/contract/test_diagram.py index 964abb4..6556bc4 100644 --- a/tests/contract/test_diagram.py +++ b/tests/contract/test_diagram.py @@ -364,3 +364,59 @@ def test_every_reserved_id_is_covered_not_just_the_one_that_broke(): def test_the_rubric_also_tells_the_model_to_avoid_them(): """Belt and braces: the rewrite is the guarantee, the instruction reduces how often it fires.""" assert "reserved" in ARCH_DIAGRAM_RUBRIC.lower() + + +# --- a `;` in sequence message text is a statement separator ---------------------------------------------- + +def _sequence(body): + return "```mermaid\nsequenceDiagram\n" + body + "```" + + +def test_a_semicolon_in_a_message_is_replaced(): + """`;` separates statements in Mermaid, and sequence message text is unquoted — so + `A->>B: one; two` parses as two statements and the block fails, with `two`'s first word read as + an actor.""" + md, fixed = authoring.escape_sequence_text(_sequence( + " A->>B: corpus mode; without --execute it prices and stops\n")) + assert fixed == 1 + assert ";" not in md + assert "corpus mode — without --execute it prices and stops" in md + + +def test_a_semicolon_in_a_note_is_replaced(): + md, fixed = authoring.escape_sequence_text(_sequence( + " Note over A: one thing; another thing\n")) + assert fixed == 1 and "one thing — another thing" in md + + +def test_the_arrow_and_actors_are_untouched(): + md, _ = authoring.escape_sequence_text(_sequence(" CLI-->>U: a; b\n")) + assert "CLI-->>U:" in md + + +def test_a_colon_inside_the_text_is_not_a_boundary(): + """Message text routinely contains a colon — a file:line citation, a label. Splitting on the + last colon instead of the first would move the replacement into the wrong half.""" + md, _ = authoring.escape_sequence_text(_sequence( + " A->>B: loads m0, m1: prompt and provenance; then runs\n")) + assert "loads m0, m1: prompt and provenance — then runs" in md + + +def test_a_clean_sequence_block_is_returned_untouched(): + src = _sequence(" A->>B: plain text with --execute inside it\n") + md, fixed = authoring.escape_sequence_text(src) + assert md == src and fixed == 0 + + +def test_flowchart_labels_are_left_alone(): + """A flowchart label is QUOTED, so a `;` there is safe. Replacing it would rewrite prose to fix + a problem that does not exist in that grammar.""" + src = "```mermaid\nflowchart LR\n a[\"stage one; stage two\"]\n```" + md, fixed = authoring.escape_sequence_text(src) + assert md == src and fixed == 0 + + +def test_a_participant_declaration_is_not_treated_as_a_message(): + src = _sequence(" participant CLI as tool (console script)\n") + md, fixed = authoring.escape_sequence_text(src) + assert md == src and fixed == 0