Skip to content

feat: Mermaid diagram export (B4) + initial-state arm fix#29

Merged
MarcelRoozekrans merged 23 commits into
mainfrom
feat/mermaid-export-and-initial-arm
May 23, 2026
Merged

feat: Mermaid diagram export (B4) + initial-state arm fix#29
MarcelRoozekrans merged 23 commits into
mainfrom
feat/mermaid-export-and-initial-arm

Conversation

@MarcelRoozekrans
Copy link
Copy Markdown
Contributor

Summary

Closes out the StateMachine roadmap with two threads:

  • B4 — Mermaid diagram export. Opt-in via [StateMachine(Diagram = true)] / [StateMachineGroup(Diagram = true)]. The generator emits a public const string MermaidDiagram with a full Mermaid stateDiagram-v2 rendering: composites nested, history pseudo-states, group parts as top-level state blocks, timed edges annotated (after Nms), guards labeled [guard], terminals as --> [*].

  • Initial-state arm follow-up (closes the v1.4 caveat). Timers now arm at construction, on every entry into the source state, and on Reset() / ResetTo(state). The generator emits private void HookConstructor() + a default ctor when the user hasn't declared one; ZSM0021 fires if a user-declared ctor doesn't invoke HookConstructor() (accepts bare-identifier, this., and base. qualified forms).

Two new diagnostics: ZSM0020 (warning: empty diagram request), ZSM0021 (error: missing HookConstructor invocation).

Design doc: docs/plans/2026-05-23-mermaid-export-and-initial-arm-design.md.
Plan: docs/plans/2026-05-23-mermaid-export-and-initial-arm.md.

Test plan

  • Generator snapshot tests for Mermaid emit (flat / composite+history / group)
  • Diagnostic tests for ZSM0020 (positive: empty class + empty group) and ZSM0021 (positive + 2 negative — bare-identifier and this-qualified)
  • Runtime tests for initial-state arm (constructor + Reset)
  • All v1.4 tests still pass (32 → 40 generator, 26 → 28 runtime)
  • Existing v1.4 snapshots regenerated to include the new ArmInitialStateTimers + HookConstructor + default ctor emit; visually inspected
  • PublicAPI.Shipped.txt untouched

Known trade-offs

  • Single-machine pipeline now models.Combine(CompilationProvider) to resolve composite sub-FSMs for nested diagrams. Partially invalidates incremental caching — acceptable cost paid only when Diagram = true.

🤖 Generated with Claude Code

MarcelRoozekrans and others added 23 commits May 23, 2026 08:45
… B3 follow-up)

Brainstormed B4 (Mermaid diagram export, opt-in via Diagram=true) and the
B3 follow-up (initial-state timer arm gap documented as a caveat in v1.4)
together since both land in StateMachineWriter / StateMachineGroupWriter
and share a release cycle.

Key decisions locked: Mermaid-only emission, public const string surface,
opt-in attribute property, full-fidelity rendering (composites nested,
parts grouped, timed annotated, guards labeled), arm helper invoked from
ctor + Reset + ResetTo, partial-void HookConstructor hook with ZSM0021
diagnostic for user-declared ctors that forget to call it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…B follow-up)

21-task TDD plan walking through:
  - Attribute Diagram property (Tasks 1, 3-4)
  - Diagnostic descriptors ZSM0020 + ZSM0021 (Task 2)
  - ZSM0020 detection (Task 5)
  - MermaidDiagramWriter: flat -> composite -> history -> groups (Tasks 6-9)
  - Wiring into single-machine + group writers (Tasks 10-11)
  - ArmInitialStateTimers helper + HookConstructor partial + default ctor
    in StateMachineWriter (Tasks 12-14)
  - Per-part arm helpers + group ctor in StateMachineGroupWriter (Task 15)
  - ZSM0021 detection (Task 16)
  - Snapshot, diagnostic, and runtime tests (Tasks 17-19)
  - Docs + PR (Tasks 20-21)

Each task lists exact files, code, build/test commands, snapshot-regen
notes, and commit message. Follows the same shape as prior plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(B4 runtime surface)

Two new opt-in boolean properties on the top-level attributes. When set to
true, the generator (next commits) emits a public const string MermaidDiagram
containing the FSM's stateDiagram-v2 rendering. Default false — existing v1.4
declarations are strictly additive.

Generator wiring lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Invocation) descriptors

ZSM0020 (Warning): fires when [StateMachine(Diagram = true)] is declared on a
class with zero transitions, since the emitted MermaidDiagram would be empty.

ZSM0021 (Error): fires when a class has timed transitions AND a user-declared
constructor that does not call HookConstructor(), preventing initial-state
timer arming.

RS1032 required a trailing period on ZSM0021's messageFormat (it has interior
'.'), matching the v1.4 deviation pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a bool Diagram positional param to StateMachineModel and
StateMachineGroupModel. Parse / ParseGroup pass false for now; the
attribute-named-arg read lands in Task 4.

Existing snapshots and runtime tests are byte-identical - Diagram = false
means no emit change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…chineGroup]

Reads the Diagram boolean from each top-level attribute and threads it into
the corresponding model. Defaults to false when absent. No writer changes
yet - emit-when-true lands in Tasks 10 + 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fires when Diagram = true is declared on a class with zero transitions
(or a group with no transitions across all parts). The emitted
MermaidDiagram would be empty / useless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial implementation covers the flat-machine case:
  - stateDiagram-v2 header
  - [*] --> InitialState marker
  - From --> To: Trigger transitions
  - (after Nms) annotation for timed edges
  - [guard] annotation for When = true
  - Terminal --> [*] markers for [Terminal]

Composite nesting, history pseudo-states, and group rendering land in
subsequent commits. Writer is currently unwired — emit-when-Diagram=true
lands in Task 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rmaidDiagramWriter

Composite states render as Mermaid 'state X { ... }' blocks containing
the sub-FSM's transitions / terminals / further-nested composites. The
sub-FSM model is resolved by walking the sub-machine's [StateMachine] +
[Transition] attributes via a new BuildModelFromSymbol helper.

Recursive: a sub-FSM that itself has composites renders correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… blocks

For any composite state that also has a matching [HistoryState] declaration,
the diagram includes a 'state H as History' pseudo-state line at the top
of the composite's nested block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in MermaidDiagramWriter

Each [StateMachinePart] becomes a 'state {Name} { ... }' block at the
top level of the stateDiagram-v2. Per-part initial state + transitions
+ timed annotations + guards render the same way as flat-machine emit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…en Diagram=true

The generator pipeline now combines the per-class model with the
CompilationProvider so the diagram writer can resolve composite sub-FSM
types to their parsed models for nested rendering. The diagram is
emitted as a public const string MermaidDiagram using a verbatim @"..."
literal for netstandard2.0 compatibility.

Caching note: Combine with CompilationProvider partially invalidates
incremental caching for [StateMachine] classes; the cost is paid only
when Diagram = true (existing tests still pass byte-identical).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er when Diagram=true

Groups don't have composites (ZSM0018 forbids them), so no sub-machine
resolver is needed; the group writer calls MermaidDiagramWriter.Write
directly with the group model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A private method emitted on every concurrent class with timed edges. Walks
all timed edges; for each whose From matches Current, arms the timer using
the existing race-safe Interlocked.CompareExchange + dispose-of-loser pattern.

Snapshot regen: existing v1.4 SingleTimedEdge / MultipleTimedEdges snapshots
gained the new method. Inspected and verified.

Tasks 13-14 invoke this helper from the ctor and from Reset/ResetTo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…riter

For every concurrent class with at least one timed edge, the generator emits:
  - private void HookConstructor() — calls ArmInitialStateTimers().
  - public {ClassName}() — calls HookConstructor() — only when the user
    has NOT declared their own ctor (detected via type.InstanceConstructors).

If the user declares a ctor, they must invoke HookConstructor() themselves
or hit ZSM0021 (Task 16).

Snapshot regen: v1.4 SingleTimedEdge / MultipleTimedEdges now include the
new ctor + hook in the emit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both internal state-population methods now call ArmInitialStateTimers()
after assigning state. This closes the v1.4 caveat: any path that lands
on a state with a timed edge arms that edge's timer.

Snapshot regen: ArmInitialStateTimers() call now appears in the Reset
and ResetTo bodies in the v1.4 timed-edge snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…StateMachineGroupWriter

For each part with at least one timed edge: a private
ArmInitialStateTimers_{PartName}() helper using the race-safe
lazy-init pattern. Plus a group-level HookConstructor that calls
each timed part's helper, and a default ctor invoking HookConstructor
when no user ctor exists.

Snapshot regen: TwoPartsOneTimedEdge gains the new arm helpers + ctor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…structor)

Syntactic walk of every user-declared ctor's body looking for an
HookConstructor() invocation. If the class has at least one timed
edge AND a user ctor AND none of the user ctors invoke HookConstructor(),
fire ZSM0021.

Best-effort: indirect invocations (via helper methods) are not detected;
users can #pragma warning disable ZSM0021 for those edge cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three scenarios:
  - Flat_Diagram: initial + transitions + guard-annotated + terminal
  - Composite_Diagram: parent diagram includes nested 'state X { ... }'
    block with 'state H as History' for the [HistoryState]
  - Group_Diagram: top-level 'state Op { ... }' + 'state Conn { ... }'

Snapshots assert correct Mermaid stateDiagram-v2 syntax, including
sub-FSM walking via the new BuildModelFromSymbol helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  ZSM0020: fires when [StateMachine(Diagram = true)] has no transitions.
  ZSM0020: fires when [StateMachineGroup(Diagram = true)] has no parts.
  ZSM0021: fires when user-declared ctor doesn't call HookConstructor().
  ZSM0021: negative -- does NOT fire when user ctor invokes HookConstructor().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two scenarios:
  - Constructor arms initial-state timer; no user TryFire needed.
  - Reset() re-arms after returning to the initial state.

Reset is internal; the test uses reflection to invoke it. Group runtime
coverage lands in a future test if a real consumer asks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  - Removed the Caveats section from timeout-transitions.md (no longer
    a caveat after the initial-arm fix).
  - New core-concepts/diagram-export.md page describing Diagram = true,
    the MermaidDiagram const surface, and what gets rendered.
  - New diagnostics pages for ZSM0020 + ZSM0021.
  - attributes.md adds Diagram entries on [StateMachine] + [StateMachineGroup].
  - index.md adds the new pages to the top-level TOC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kConstructor()

The CtorInvokesHookConstructor syntactic walk only matched bare
IdentifierNameSyntax. Users writing the idiomatic this.HookConstructor()
hit a false-positive ZSM0021. Extended the match to also accept
MemberAccessExpressionSyntax where the receiver is ThisExpressionSyntax
or BaseExpressionSyntax and the name is 'HookConstructor'.

Added a negative test covering this.HookConstructor() to lock the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MarcelRoozekrans MarcelRoozekrans merged commit ca73708 into main May 23, 2026
3 of 4 checks passed
@MarcelRoozekrans MarcelRoozekrans deleted the feat/mermaid-export-and-initial-arm branch May 23, 2026 08:29
MarcelRoozekrans added a commit that referenced this pull request May 24, 2026
The original post-v1 graduation backlog (B1 composite states, B2 shallow
history, B3 timeout transitions, B4 Mermaid diagram, B5 concurrent state
parts) fully shipped across PRs #25, #27, and #29. The backlog file
hadn't been updated to reflect this — now it has.

Each entry now carries a brief 'Shipped:' note pointing at the relevant
attribute / generator file / test file so a reader can navigate from the
backlog item to the implementation. No new backlog items added — future
graduation candidates land here when real-world friction surfaces them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant