From a31eb884c7c4136d5eea1be9eb66a25ef5708464 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 08:45:06 +0200 Subject: [PATCH 01/23] docs: add design for Mermaid diagram export + initial-state arm (B4 + 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) --- ...3-mermaid-export-and-initial-arm-design.md | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/plans/2026-05-23-mermaid-export-and-initial-arm-design.md diff --git a/docs/plans/2026-05-23-mermaid-export-and-initial-arm-design.md b/docs/plans/2026-05-23-mermaid-export-and-initial-arm-design.md new file mode 100644 index 0000000..ca3b4eb --- /dev/null +++ b/docs/plans/2026-05-23-mermaid-export-and-initial-arm-design.md @@ -0,0 +1,282 @@ +# Mermaid diagram export + initial-state timer arm (backlog B4 + B3 follow-up) + +Date: 2026-05-23 +Status: Approved +Backlog: graduates `ZeroAlloc.StateMachine` item B4; closes the v1.4 follow-up flagged in `docs/core-concepts/timeout-transitions.md#caveats` + +## Problem + +v1.4 of `ZeroAlloc.StateMachine` shipped B3 (timeout transitions) + B5 +(concurrent state parts). Two loose ends remain in the StateMachine +roadmap: + +- **B4: Visual diagram export.** A state machine's transition table is a + graph, and graphs read better as diagrams than as `[Transition]` + attribute stacks. Mermaid's `stateDiagram-v2` syntax renders inline + in GitHub READMEs, Mermaid Live Editor, and most docs sites — so the + generator can ship a ready-to-paste diagram for every FSM. +- **B3 follow-up: initial-state arm gap.** v1.4's emit arms timers only + inside `TryFire`'s post-CAS success block. If a user declares + `InitialState = "Working"` with a `[Transition(From = Working, ..., + AfterMs = 5000)]`, the `Working → Dead` timer never arms until + something transitions INTO `Working` from elsewhere. Documented as a + caveat at v1.4 ship; this lifts it into actual behavior. + +The two are independent on code paths but ship together because both +land in `StateMachineWriter` / `StateMachineGroupWriter` and share a +release cycle. + +## Goals + +- Opt-in `MermaidDiagram` const on the generated partial for every + `[StateMachine]` / `[StateMachineGroup]` class with `Diagram = true`. +- Full-fidelity rendering: composites nested, parts grouped, timed + edges annotated, guards labeled, initial + terminal markers, history + pseudo-states. +- Zero-allocation steady state preserved — `MermaidDiagram` is a + `const string`, not a property or method. +- AOT-friendly, generator-driven, no reflection. +- Initial-state timer arm at construction (and at `Reset()` / + `ResetTo(state)`) so the documented behavior matches the implemented + behavior. + +## Decisions + +Locked during brainstorming. Each pinned via Q&A in the session. + +| Question | Decision | +|---|---| +| Diagram format | Mermaid only (PlantUML deferred until a real consumer asks) | +| Delivery mechanism | `public const string MermaidDiagram` on the generated partial (no filesystem output, no MSBuild target) | +| Opt-in vs always-on | Opt-in via `[StateMachine(Diagram = true)]` + `[StateMachineGroup(Diagram = true)]`; default off | +| Rendering scope | Full feature parity — initial, terminal, composite-nested, group-bucketed, timed-annotated, guard-labeled, history pseudo-state | +| Group diagrams | One combined diagram per group; each part wrapped in `state {Name} { ... }` at top level | +| Initial-arm scope | Constructor + `Reset()` + `ResetTo(state)` all arm via a shared `ArmInitialStateTimers()` helper | +| User-declared ctor collision | Diagnose with `ZSM0021`; tell user to call the generated `HookConstructor()` partial hook | + +## Design + +### B4 — New / extended public attributes + +```csharp +namespace ZeroAlloc.StateMachine; + +// EXTENDED — new optional property +public sealed class StateMachineAttribute : Attribute +{ + // ... existing InitialState / Concurrent ... + public bool Diagram { get; init; } = false; +} + +// EXTENDED — new optional property +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class StateMachineGroupAttribute : Attribute +{ + public bool Diagram { get; init; } = false; +} +``` + +When `Diagram = true`, the generator emits: + +```csharp +partial class Watchdog +{ + /// Mermaid stateDiagram-v2 representation of this state machine. + public const string MermaidDiagram = """ + stateDiagram-v2 + [*] --> Idle + Idle --> Working: Start + Working --> Working: Heartbeat + Working --> Dead: Timeout (after 5000ms) + Dead --> [*] + """; +} +``` + +(C# 11 raw string literals; falls back to a quoted multi-line string for +`netstandard2.0` source-generator emission. The emit writer chooses +based on the target's language version.) + +### B4 — Rendering rules + +| Model element | Mermaid emit | +|---|---| +| `InitialState = "Idle"` | `[*] --> Idle` | +| `[Transition(From=A, On=T, To=B)]` | `A --> B: T` | +| `[Transition(... AfterMs = N)]` | `A --> B: T (after Nms)` | +| `[Transition(... When = true)]` | `A --> B: T [guard]` | +| `[Transition(...)]` with both | `A --> B: T (after Nms) [guard]` | +| `[Terminal(State = X)]` | `X --> [*]` | +| `[CompositeState(State = X, SubMachine = typeof(Sub))]` | `state X { …sub-FSM rendered recursively… }` | +| `[HistoryState(State = X)]` | inside the `state X { ... }` block: `state H as History` plus `[*] --> H` | +| `[StateMachineGroup]` with parts P, Q | one diagram with `state P { ... }` + `state Q { ... }` siblings | + +Cross-class composite rendering reuses the existing +`ResolveSubMachineSymbol` helper (v1.3); the sub-FSM's transitions are +walked via `GetAttributes()` on the metadata symbol, so cross-assembly +composites render correctly. + +### B4 — New diagnostic + +| ID | Severity | Condition | +|---|---|---| +| `ZSM0020` | Warning | `[StateMachine(Diagram = true)]` (or `[StateMachineGroup(Diagram = true)]`) on a class with zero transitions — the diagram would be empty | + +### B4 — Files touched + +- MOD: `src/ZeroAlloc.StateMachine/StateMachineAttribute.cs` (add `Diagram` property) +- MOD: `src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs` (add `Diagram` property) +- MOD: `src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt` +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs` (add `Diagram` flag) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs` (add `Diagram` flag) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` (read the new property) +- NEW: `src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs` (emit the diagram body; consumed by both `StateMachineWriter` and `StateMachineGroupWriter`) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs` (call into `MermaidDiagramWriter` when `model.Diagram`) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs` (same) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs` (add `ZSM0020`) + +### B — Initial-state timer arm + +**New generator-emitted helper (per class with at least one timed edge):** + +```csharp +private void ArmInitialStateTimers() +{ + var current = Current; + if (current == WdState.Working) + { + // Same race-safe lazy-init pattern as the in-TryFire arm path. + var __t = _timer_Working_Timeout; + if (__t is null) + { + var __new = new System.Threading.Timer( + static s => ((Watchdog)s!).TryFire(WdTrigger.Timeout), + this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + __t = System.Threading.Interlocked.CompareExchange(ref _timer_Working_Timeout, __new, null) ?? __new; + if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose(); + } + __t.Change(5000, System.Threading.Timeout.Infinite); + } + // ... one block per (From, On) timed edge +} +``` + +**Call sites (all generated):** +1. Constructor: see "User-declared ctor handling" below. +2. `Reset()`: append `ArmInitialStateTimers();` after `_state = InitialState;`. +3. `ResetTo(state)`: append `ArmInitialStateTimers();` after `_state = state;`. + +For `[StateMachineGroup]`: one `ArmInitialStateTimers_{PartName}()` helper per part; the group ctor calls each in turn. Groups don't emit `Reset` / `ResetTo`, so the helper is only invoked from the ctor. + +### B — User-declared ctor handling + +The generator emits `partial void HookConstructor();` plus an +implementing declaration that calls `ArmInitialStateTimers()`: + +```csharp +partial class Watchdog +{ + partial void HookConstructor() + { + ArmInitialStateTimers(); + } +} +``` + +If the user has NOT declared their own ctor, the generator additionally +emits a default ctor that calls the hook: + +```csharp +public Watchdog() => HookConstructor(); +``` + +If the user HAS declared a ctor, the generator detects this via Roslyn +symbol inspection (`type.InstanceConstructors`) and emits ONLY the +partial hook — the user is responsible for calling +`HookConstructor()` from their own ctor. If they forget, the diagnostic +fires: + +| ID | Severity | Condition | +|---|---|---| +| `ZSM0021` | Error | Class declares a user-defined ctor but its body does not invoke `HookConstructor()`. Detection is best-effort syntactic — walk the ctor's `SyntaxNode` for an `HookConstructor()` invocation. | + +Detection caveat: `ZSM0021` uses a syntactic walk, so it can miss +indirect invocations (e.g., `HookConstructor()` called from a helper). +Documented as a known limitation; a user who wants the indirect-call +shape can `#pragma warning disable ZSM0021` to silence. + +### B — Composite + initial-arm interaction + +Composite states are mutually exclusive with concurrent mode +(`ZSM0005`), and timed edges require concurrent (`ZSM0012`). So +composite + timed is transitively impossible. No special-case logic +needed for `[CompositeState]` in the arm helper. + +### B — Docs change + +- `docs/core-concepts/timeout-transitions.md`: remove the "Caveats" + section (no longer a caveat); update the body to state that timers + arm "on construction, on entry, and on `Reset()` / `ResetTo(state)`". + +### B — Files touched + +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs` (emit `ArmInitialStateTimers` + hook + default ctor; invoke from `Reset` / `ResetTo`) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs` (per-part arm helpers + group ctor) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` (detect user-declared ctor; emit `ZSM0021` if hook not called) +- MOD: `src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs` (add `ZSM0021`) +- MOD: `docs/core-concepts/timeout-transitions.md` (remove caveats, update body) + +## Testing + +`MermaidDiagramGeneratorTests.cs` (new): +- Flat machine — initial, terminal, plain transition, timed-annotated, + guard-labeled all render correctly. +- Composite — nested `state X { ... }` block contains the sub-FSM. +- Group — combined diagram with two `state {Name} { ... }` siblings. +- `Diagram = false` (or absent) — no `MermaidDiagram` const emitted. +- `ZSM0020` fires when `Diagram = true` and zero transitions. + +`InitialStateArmTests.cs` (new, runtime): +- Initial state is the source of a timed edge — timer fires after the + configured duration without any user `TryFire`. +- `Reset()` re-arms initial-state timers. +- `ResetTo(state)` arms whichever state's timers apply. +- Group with timed-edge in one part — only that part's initial-state + timer arms; other part is unaffected. + +`DiagnosticTests.cs` (modify): +- `ZSM0020` positive (Diagram = true + zero transitions). +- `ZSM0021` positive (user-declared ctor without `HookConstructor()` invocation). +- `ZSM0021` negative (user-declared ctor WITH the invocation — no diagnostic). + +## Out of scope + +- PlantUML emission (deferred until a real consumer asks). +- Filesystem output (`{TypeName}.mermaid` alongside `.g.cs`). Achievable + via MSBuild target but adds infrastructure not currently justified. +- Per-part separate `MermaidDiagram_{PartName}` constants. One combined + diagram per group is more discoverable; per-part can be split out in + a follow-up if a consumer asks. +- Diagram styling / theming (e.g. Mermaid `classDef` blocks). +- Runtime diagram rendering helper (e.g. `Watchdog.RenderToConsole()`). + Users have the const string; they can do what they want with it. +- Auto-arming inside the generated `OnEnter` partial hooks. Existing + emit already covers this via the post-CAS arm path; the initial-arm + fix only patches the construction + reset corners. + +## Backward compatibility + +Strictly additive: +- Two new optional properties (`Diagram`) on existing attributes; default + `false` preserves v1.4 behavior. +- New `MermaidDiagram` const appears only when `Diagram = true`. +- `ArmInitialStateTimers` is a new private method; no public surface + change. +- Generated ctor only appears when at least one timed edge exists AND + no user-declared ctor is present. +- The `HookConstructor` partial is a `partial void` — never required + to be implemented; its existence does not change consumer code paths. +- No changes to `PublicAPI.Shipped.txt`; everything new lands in + `PublicAPI.Unshipped.txt`. + +No SemVer break. Lands as a `feat:` commit, minor bump (1.4.x → 1.5.0). From 84fcb127ab0cc410afd8584214039696ce8a3e6e Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 08:52:16 +0200 Subject: [PATCH 02/23] docs: add implementation plan for Mermaid export + initial-arm (B4 + 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) --- ...26-05-23-mermaid-export-and-initial-arm.md | 1850 +++++++++++++++++ 1 file changed, 1850 insertions(+) create mode 100644 docs/plans/2026-05-23-mermaid-export-and-initial-arm.md diff --git a/docs/plans/2026-05-23-mermaid-export-and-initial-arm.md b/docs/plans/2026-05-23-mermaid-export-and-initial-arm.md new file mode 100644 index 0000000..93066db --- /dev/null +++ b/docs/plans/2026-05-23-mermaid-export-and-initial-arm.md @@ -0,0 +1,1850 @@ +# Mermaid diagram export + initial-state arm Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Land `ZeroAlloc.StateMachine` backlog item B4 (opt-in Mermaid `stateDiagram-v2` diagram emitted as a `public const string MermaidDiagram` on every `[StateMachine(Diagram = true)]` / `[StateMachineGroup(Diagram = true)]` class) and close the v1.4 follow-up by emitting initial-state timer-arm calls in the generated constructor and from `Reset()` / `ResetTo(state)`. + +**Architecture:** One new optional property (`Diagram`) on each of the two top-level attributes. A new `MermaidDiagramWriter` walks the model and emits a Mermaid `stateDiagram-v2` body (initial / transitions / terminals / composites nested / history pseudo-state / group parts as top-level `state X { … }` blocks / timed-annotated / guard-labeled). The arm-on-construct fix lands as a generator-emitted `private void ArmInitialStateTimers()` helper plus a `partial void HookConstructor()` invocation pattern: the generator emits the partial-void implementing declaration that arms timers; it also emits a default parameterless ctor calling that hook only when the user has NOT declared their own ctor. `Reset()` and `ResetTo(state)` gain a trailing `ArmInitialStateTimers()` call. Two new diagnostics — `ZSM0020` (Diagram = true on a class with zero transitions) and `ZSM0021` (user-declared ctor doesn't invoke `HookConstructor()`). + +**Tech Stack:** .NET 10 / netstandard2.0 (Roslyn source generator targets), Roslyn `IIncrementalGenerator`, `Microsoft.CodeAnalysis.PublicApiAnalyzers` (RS0016/RS0017), xUnit + VerifyXunit (existing snapshot test convention via `tests/.../GeneratorSnapshotTests.cs` + `TestHelper.cs`). + +**Design doc:** `docs/plans/2026-05-23-mermaid-export-and-initial-arm-design.md` (committed in `a31eb88`) + +**Working branch:** `feat/mermaid-export-and-initial-arm` (already created off `main`; design doc commit `a31eb88` is the current HEAD). + +**Key context:** +- v1.4 just shipped (PR #27) adding B3 (timed transitions) + B5 (concurrent state parts). The generator already has `StateMachineWriter.cs` (526 lines), `StateMachineGroupWriter.cs` (193 lines), `StateMachineGenerator.cs` (857 lines), `StateMachineDiagnostics.cs` (177 lines). +- Existing race-safe lazy-timer pattern uses `Interlocked.CompareExchange(ref _field, __new, null) ?? __new` + `if (!ReferenceEquals(__t, __new)) __new.Dispose();` followed by an unconditional `__t.Change(AfterMs, Timeout.Infinite)`. Initial-arm code reuses this same pattern. +- The `Current` property on single-machine classes exposes the current state as a read-only `TState`. On groups, each part has its own `Current` property. +- Existing diagnostics max ID is `ZSM0021` — wait, current max is `ZSM0019` (v1.4's set). New ones in this PR start at `ZSM0020`. +- `TreatWarningsAsErrors=true` repo-wide via `Directory.Build.props`. PublicAPI mismatches (RS0016/RS0017) WILL fail the build. +- Repo conventions: + - MA0051 (Meziantou): methods may not exceed 60 lines. + - RS1032 (CodeAnalysis): diagnostic `messageFormat` strings must be single sentences (no interior `.` unless paired with a trailing `.`). + - HLQ012 (NetFabric.Hyperlinq): avoid named tuples in foreach over `List<...>`; prefer `record struct` carriers. + - MA0006: prefer `string.Equals(a, b, StringComparison.Ordinal)` over `==` on strings (treated as error). + - All four surfaced during v1.4 implementation. Methods/code should anticipate them. +- Snapshot tests use `TestHelper.Verify(source)` (VerifyXunit). First run writes `.received.cs`; rename to `.verified.cs` to lock the snapshot. Hint names: `{ns}_{ClassName}.g.cs` for single-machine emit, `{ns}_{ClassName}.Group.g.cs` for groups. + +--- + +## Task 1: Add `Diagram` property to `[StateMachine]` + `[StateMachineGroup]` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine/StateMachineAttribute.cs` +- Modify: `src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs` +- Modify: `src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt` + +**Step 1: Add `Diagram` to `StateMachineAttribute.cs`** + +Append after the existing `Concurrent` property: + +```csharp + /// + /// When true, the generator emits a public const string MermaidDiagram + /// on the partial containing a Mermaid stateDiagram-v2 rendering of the + /// machine's transitions. Composite sub-FSMs render as nested state X { ... } + /// blocks; timed edges annotate with (after Nms); guards annotate with + /// [guard]; terminal states render as X --> [*]. + /// Default: false. + /// + public bool Diagram { get; init; } = false; +``` + +**Step 2: Add `Diagram` to `StateMachineGroupAttribute.cs`** + +Replace the empty body with: + +```csharp + /// + /// When true, the generator emits a public const string MermaidDiagram + /// on the group partial. Each + /// renders as a top-level state {Name} { ... } block; transitions, terminals, + /// timed edges, and guards render per the standard Mermaid rules. + /// Default: false. + /// + public bool Diagram { get; init; } = false; +``` + +**Step 3: Update `PublicAPI.Unshipped.txt`** + +Append (alphabetical order within the existing attribute blocks): + +``` +ZeroAlloc.StateMachine.StateMachineAttribute.Diagram.get -> bool +ZeroAlloc.StateMachine.StateMachineAttribute.Diagram.init -> void +ZeroAlloc.StateMachine.StateMachineGroupAttribute.Diagram.get -> bool +ZeroAlloc.StateMachine.StateMachineGroupAttribute.Diagram.init -> void +``` + +If `RS0016`/`RS0017` fires, accept the analyzer's suggested form verbatim. + +**Step 4: Verify build** + +```bash +cd c:/Projects/Prive/ZeroAlloc/ZeroAlloc.StateMachine +dotnet build src/ZeroAlloc.StateMachine/ZeroAlloc.StateMachine.csproj -c Release +``` + +Expected: 0 warnings, 0 errors. + +**Step 5: Commit** + +```bash +git add src/ZeroAlloc.StateMachine/StateMachineAttribute.cs \ + src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs \ + src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt +git commit -m "$(cat <<'EOF' +feat: add Diagram property to [StateMachine] and [StateMachineGroup] (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. +EOF +)" +``` + +Use git's heredoc form. End each commit message with the standard +`Co-Authored-By: Claude Opus 4.7 (1M context) ` trailer. + +--- + +## Task 2: Add diagnostic descriptors `ZSM0020` + `ZSM0021` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs` + +**Step 1: Append the new descriptors** + +Add to the end of the `StateMachineDiagnostics` class (after `DisposeSignatureConflict` which is the last v1.4 entry): + +```csharp + public static readonly DiagnosticDescriptor EmptyDiagramRequest = new( + id: "ZSM0020", + title: "[StateMachine(Diagram = true)] on a class with zero transitions", + messageFormat: "'{0}' declares Diagram = true but has no transitions; the emitted MermaidDiagram would be empty", + category: "ZeroAlloc.StateMachine", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Either remove Diagram = true or add at least one [Transition] (or [StateMachinePart] with its own transitions for a group)."); + + public static readonly DiagnosticDescriptor MissingHookConstructorInvocation = new( + id: "ZSM0021", + title: "User-declared constructor must call HookConstructor()", + messageFormat: "'{0}' has at least one timed transition AND a user-declared constructor that does not invoke HookConstructor(). Add a HookConstructor() call so the generator can arm initial-state timers", + category: "ZeroAlloc.StateMachine", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "When timed transitions are present and the user declares their own constructor, that constructor must call the generator-emitted partial void HookConstructor() to arm initial-state timers."); +``` + +> **RS1032 note:** Both messageFormat strings have an interior `.` (and `;` in 0020) — the trailing period in 0021 makes RS1032 happy with multi-clause; 0020's single-clause shape with no interior period (the `;` is fine) is also accepted. If RS1032 fires anyway, append a trailing `.` to keep the closest match to the original semantic, mirroring the Task 3 deviation from the v1.4 plan. + +**Step 2: Verify build** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +``` + +Expected: 0 errors. + +**Step 3: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs +git commit -m "$(cat <<'EOF' +feat(generator): add diagnostic descriptors ZSM0020 + ZSM0021 (B4 + initial-arm) + +Two new diagnostics: + + ZSM0020 (Warning): Diagram = true declared on a class with zero + transitions — emitted MermaidDiagram would be empty. + ZSM0021 (Error): user-declared ctor on a class with timed transitions + must invoke HookConstructor() so initial-state timers arm. + +Descriptors only — wiring (detection + report) lands in subsequent commits. +EOF +)" +``` + +Add the co-author trailer. + +--- + +## Task 3: Add `Diagram` flag to `StateMachineModel` + `StateMachineGroupModel` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs` +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs` + +**Step 1: Extend `StateMachineModel`** + +Add `bool Diagram` as a positional param immediately before the trailing `Diagnostics`: + +```csharp +internal sealed record StateMachineModel( + string? Namespace, + string ClassName, + bool IsStruct, + string InitialState, + bool Concurrent, + string StateTypeFqn, + string StateTypeShort, + string TriggerTypeFqn, + string TriggerTypeShort, + ImmutableArray Transitions, + ImmutableArray TerminalStates, + ImmutableArray CompositeStates, + ImmutableArray HistoryStates, + bool Diagram, // NEW + ImmutableArray Diagnostics +); +``` + +**Step 2: Extend `StateMachineGroupModel`** + +```csharp +internal sealed record StateMachineGroupModel( + string? Namespace, + string ClassName, + ImmutableArray Parts, + bool Diagram, // NEW + ImmutableArray Diagnostics +); +``` + +**Step 3: Update the single-machine constructor call in `StateMachineGenerator.Parse`** + +In `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs`, find the `new StateMachineModel(...)` call inside `Parse` (currently the last expression of that method). Insert `false` as the new positional arg right before `diagnostics.ToImmutable()`: + +```csharp +return new StateMachineModel( + ns, type.Name, isStruct, + initialState, concurrent, + stateTypeFqn, stateTypeShort!, + triggerTypeFqn, triggerTypeShort!, + transitions, terminalStates, + compositeStates, historyStates, + diagram: false, // NEW — Task 4 reads the actual value + diagnostics.ToImmutable()); +``` + +Use the named-argument form so a future reader can find the parse site. + +**Step 4: Update the group constructor call in `StateMachineGenerator.ParseGroup`** + +In the same file, find the `new StateMachineGroupModel(...)` call in `ParseGroup`: + +```csharp +return new StateMachineGroupModel( + ns, type.Name, parts, + diagram: false, // NEW — Task 4 reads the actual value + diagnostics.ToImmutable()); +``` + +**Step 5: Verify build + existing tests still pass** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ZeroAlloc.StateMachine.Generator.Tests.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ZeroAlloc.StateMachine.Tests.csproj -c Release +``` + +Expected: 32/32 + 26/26 tests pass. Behavior change: none yet (Diagram defaults to false). + +**Step 6: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +git commit -m "$(cat <<'EOF' +feat(generator): extend models with Diagram flag + +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. +EOF +)" +``` + +--- + +## Task 4: Read `Diagram` from the attribute's named args + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` + +**Step 1: Read `Diagram` in `Parse` (single-machine path)** + +In `Parse`, find the line that reads the `Concurrent` named arg: + +```csharp +var concurrent = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Concurrent", StringComparison.Ordinal)).Value.Value is true; +``` + +Immediately after it, add: + +```csharp +var diagram = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; +``` + +Then update the `new StateMachineModel(...)` call: replace `diagram: false,` with `diagram: diagram,`. + +**Step 2: Read `Diagram` in `ParseGroup`** + +In `ParseGroup`, near the top (right after the namespace + diagnostics-builder setup): + +```csharp +var groupAttr = ctx.Attributes[0]; +var diagram = groupAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; +``` + +Then update the `new StateMachineGroupModel(...)` call: replace `diagram: false,` with `diagram: diagram,`. + +**Step 3: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. + +**Step 4: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +git commit -m "$(cat <<'EOF' +feat(generator): parse Diagram named arg on [StateMachine] + [StateMachineGroup] + +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. +EOF +)" +``` + +--- + +## Task 5: Detect ZSM0020 (Diagram = true on a class with zero transitions) + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` + +**Step 1: Add the analyzer** + +Append to `StateMachineGenerator` (after `AnalyzeDisposeConflict`, the last v1.4 analyzer): + +```csharp + private static void AnalyzeEmptyDiagramRequest( + bool diagram, + ImmutableArray transitions, + INamedTypeSymbol type, + ImmutableArray.Builder diagnostics) + { + if (!diagram) return; + if (!transitions.IsEmpty) return; + + var location = type.Locations.Length > 0 ? type.Locations[0] : Location.None; + diagnostics.Add(Diagnostic.Create( + StateMachineDiagnostics.EmptyDiagramRequest, location, type.Name)); + } +``` + +**Step 2: Wire it from `AnalyzeDiagnostics`** (single-machine path) + +Inside `AnalyzeDiagnostics`, append after the existing `AnalyzeDisposeConflict(...)` call: + +```csharp + AnalyzeEmptyDiagramRequest(diagram, transitions, type, diagnostics); +``` + +You'll need to add `bool diagram` to `AnalyzeDiagnostics`'s signature and propagate it from the `Parse` callsite. Add the param near the end of the existing signature (before the trailing `diagnostics` builder). + +**Step 3: Wire it from `AnalyzeGroupDiagnostics`** (group path) + +In `AnalyzeGroupDiagnostics`, append after the existing analyzers: + +```csharp + var anyTransition = parts.Any(static p => !p.Transitions.IsEmpty); + if (diagram && !anyTransition) + { + diagnostics.Add(Diagnostic.Create( + StateMachineDiagnostics.EmptyDiagramRequest, location, type.Name)); + } +``` + +Add `bool diagram` to `AnalyzeGroupDiagnostics`'s signature and propagate from `ParseGroup`. + +**Step 4: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. + +**Step 5: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +git commit -m "$(cat <<'EOF' +feat(generator): detect ZSM0020 (empty Diagram request) + +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. +EOF +)" +``` + +--- + +## Task 6: Create `MermaidDiagramWriter.cs` — flat machine rendering + +**Files:** +- Create: `src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs` + +**Step 1: Add the writer** + +```csharp +namespace ZeroAlloc.StateMachine.Generator; + +using System.Linq; +using System.Text; + +/// +/// Emits Mermaid stateDiagram-v2 body content from a or +/// . The output is the diagram body only — callers wrap +/// it in a public const string MermaidDiagram = "..." literal in the generated partial. +/// +internal static class MermaidDiagramWriter +{ + /// Emit a Mermaid stateDiagram-v2 body for a single-machine model. + public static string Write(StateMachineModel m) + { + var sb = new StringBuilder(); + sb.AppendLine("stateDiagram-v2"); + + WriteIndented(sb, m, indent: " "); + + return sb.ToString().TrimEnd(); + } + + private static void WriteIndented(StringBuilder sb, StateMachineModel m, string indent) + { + // Initial-state marker. + sb.Append(indent).Append("[*] --> ").AppendLine(m.InitialState); + + // Transitions. + foreach (var t in m.Transitions) + { + sb.Append(indent); + sb.Append(t.From).Append(" --> ").Append(t.To).Append(": ").Append(t.On); + if (t.AfterMs > 0) + sb.Append(" (after ").Append(t.AfterMs).Append("ms)"); + if (t.HasGuard) + sb.Append(" [guard]"); + sb.AppendLine(); + } + + // Terminal states. + foreach (var s in m.TerminalStates) + { + sb.Append(indent).Append(s).AppendLine(" --> [*]"); + } + } +} +``` + +> **MA0051 note:** `WriteIndented` is ~15 lines; will grow in Tasks 7-8 (composites + history) and Task 9 (groups). Watch the 60-line ceiling and split into helpers when needed. + +**Step 2: Verify build** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +``` + +Expected: 0/0 (the writer is unused; build only verifies syntax). + +**Step 3: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs +git commit -m "$(cat <<'EOF' +feat(generator): add MermaidDiagramWriter for flat-machine rendering + +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. +EOF +)" +``` + +--- + +## Task 7: Add composite-state nesting to `MermaidDiagramWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs` +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` (expose a helper to walk sub-FSM transitions) + +**Step 1: Add a sub-FSM model accessor** + +The composite render needs to walk the sub-FSM's transitions. The existing parser has a `ResolveSubMachineSymbol(parent, stateName)` helper. Add a new public method `BuildSubMachineModel(INamedTypeSymbol subType)` to `StateMachineGenerator` that returns a `StateMachineModel?` for a sub-FSM. It walks the sub-FSM's `[Transition]` / `[CompositeState]` / `[HistoryState]` / `[Terminal]` / `[StateMachine]` attributes the same way `Parse` does, just without the `GeneratorAttributeSyntaxContext` (it takes a raw `INamedTypeSymbol`). + +Actually simpler: factor the model-building logic out of `Parse` into a new private `BuildModelFromSymbol(INamedTypeSymbol type)` method that returns `StateMachineModel?`. `Parse` calls it with the matched type; the diagram writer also calls it (via a new `internal` accessor) with each composite's sub-FSM type. + +Outline of the refactor: + +```csharp +internal static StateMachineModel? BuildModelFromSymbol(INamedTypeSymbol type) +{ + // Find the [StateMachine] attribute on this type. + var smAttr = type.GetAttributes() + .FirstOrDefault(a => string.Equals(a.AttributeClass?.MetadataName, StateMachineAttributeMetadataName, StringComparison.Ordinal)); + if (smAttr is null) return null; + + var initialState = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "InitialState", StringComparison.Ordinal)).Value.Value as string ?? string.Empty; + var concurrent = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Concurrent", StringComparison.Ordinal)).Value.Value is true; + var diagram = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; + + var (transitions, terminalStates, compositeStates, historyStates, + stateTypeFqn, stateTypeShort, triggerTypeFqn, triggerTypeShort) + = CollectAttributes(type); + + if (transitions.IsEmpty) return null; + if (stateTypeFqn is null || triggerTypeFqn is null) return null; + if (string.IsNullOrEmpty(initialState)) return null; + + var ns = type.ContainingNamespace.IsGlobalNamespace + ? null + : type.ContainingNamespace.ToDisplayString(); + var isStruct = type.TypeKind == TypeKind.Struct; + + return new StateMachineModel( + ns, type.Name, isStruct, + initialState, concurrent, + stateTypeFqn, stateTypeShort!, + triggerTypeFqn, triggerTypeShort!, + transitions, terminalStates, + compositeStates, historyStates, + diagram: diagram, + ImmutableArray.Empty); // diagnostics not relevant for sub-FSM diagram +} +``` + +Update `Parse` to call this helper and then run diagnostic analysis on the returned model (or inline-compose — your call). + +**Step 2: Extend `MermaidDiagramWriter.WriteIndented` to render composites** + +Update `WriteIndented` to take an optional `Func resolveSubMachine` parameter (so the test code can stub it). Walk `m.CompositeStates`. For each composite state, emit a `state {StateName} { ... }` block: + +```csharp +foreach (var c in m.CompositeStates) +{ + var subModel = resolveSubMachine?.Invoke(c.SubMachineFqn); + if (subModel is null) continue; + + sb.Append(indent).Append("state ").Append(c.State).AppendLine(" {"); + WriteIndented(sb, subModel, indent + " "); + sb.Append(indent).AppendLine("}"); +} +``` + +The `Write(StateMachineModel m)` public entry now needs a sub-machine resolver. Change its signature: + +```csharp +public static string Write(StateMachineModel m, System.Func resolveSubMachine) +{ + var sb = new StringBuilder(); + sb.AppendLine("stateDiagram-v2"); + WriteIndented(sb, m, indent: " ", resolveSubMachine); + return sb.ToString().TrimEnd(); +} +``` + +The wiring code in `StateMachineWriter` (Task 10) will pass a closure that resolves sub-machine FQNs to `StateMachineModel` instances via `StateMachineGenerator.BuildModelFromSymbol`. + +> **MA0051 note:** `WriteIndented` now scans transitions, terminals, AND composites. Split it into `WriteIndented`, `WriteTransitions`, `WriteTerminals`, `WriteComposites` to stay well under 60 lines. + +**Step 3: Verify build** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. + +**Step 4: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +git commit -m "$(cat <<'EOF' +feat(generator): render composite states as nested state blocks in MermaidDiagramWriter + +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. +EOF +)" +``` + +--- + +## Task 8: Add history pseudo-state rendering + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs` + +**Step 1: Annotate history-marked composites** + +Inside `WriteComposites` (the helper extracted in Task 7), after opening a composite block whose state is also in `m.HistoryStates`, emit a history pseudo-state line BEFORE recursing into the sub-FSM body: + +```csharp +foreach (var c in m.CompositeStates) +{ + var subModel = resolveSubMachine?.Invoke(c.SubMachineFqn); + if (subModel is null) continue; + + sb.Append(indent).Append("state ").Append(c.State).AppendLine(" {"); + + var hasHistory = m.HistoryStates.Any(h => string.Equals(h.State, c.State, StringComparison.Ordinal)); + if (hasHistory) + { + sb.Append(indent).AppendLine(" state H as History"); + } + + WriteIndented(sb, subModel, indent + " ", resolveSubMachine); + sb.Append(indent).AppendLine("}"); +} +``` + +(Mermaid `state H as History` is the standard syntax for a shallow-history pseudo-state. The arrow into H is implicit — Mermaid renders the H marker without an explicit `[*] --> H` line.) + +**Step 2: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. + +**Step 3: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs +git commit -m "$(cat <<'EOF' +feat(generator): render shallow history pseudo-state inside composite 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. +EOF +)" +``` + +--- + +## Task 9: Add group rendering to `MermaidDiagramWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs` + +**Step 1: Add group entry point** + +Add a new public method: + +```csharp +public static string Write(StateMachineGroupModel m) +{ + var sb = new StringBuilder(); + sb.AppendLine("stateDiagram-v2"); + + foreach (var p in m.Parts) + { + sb.Append(" state ").Append(p.Name).AppendLine(" {"); + WritePart(sb, p, indent: " "); + sb.AppendLine(" }"); + } + + return sb.ToString().TrimEnd(); +} + +private static void WritePart(StringBuilder sb, StateMachinePartModel p, string indent) +{ + sb.Append(indent).Append("[*] --> ").AppendLine(p.InitialState); + + foreach (var t in p.Transitions) + { + sb.Append(indent); + sb.Append(t.From).Append(" --> ").Append(t.To).Append(": ").Append(t.On); + if (t.AfterMs > 0) + sb.Append(" (after ").Append(t.AfterMs).Append("ms)"); + if (t.HasGuard) + sb.Append(" [guard]"); + sb.AppendLine(); + } + + // Groups never have composites or history (ZSM0018 blocks composites in groups); + // groups also don't have a TerminalStates field on the part model. +} +``` + +> **Note:** Groups don't render terminals because `StateMachinePartModel` doesn't track them — that field lives on `StateMachineModel` only. If a need arises, it can be added in a follow-up. + +**Step 2: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. + +**Step 3: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs +git commit -m "$(cat <<'EOF' +feat(generator): render [StateMachineGroup] as top-level state blocks 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. +EOF +)" +``` + +--- + +## Task 10: Wire `MermaidDiagramWriter` into `StateMachineWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs` + +**Step 1: Add a `WriteMermaidDiagram` helper** + +Append: + +```csharp + private static void WriteMermaidDiagram(StringBuilder sb, StateMachineModel m, + System.Func resolveSubMachine) + { + if (!m.Diagram) return; + + var diagram = MermaidDiagramWriter.Write(m, resolveSubMachine); + sb.AppendLine(); + sb.AppendLine($" /// Mermaid stateDiagram-v2 representation of this state machine."); + sb.Append($" public const string MermaidDiagram = "); + AppendQuotedMultiline(sb, diagram); + sb.AppendLine(";"); + } + + private static void AppendQuotedMultiline(StringBuilder sb, string raw) + { + // Emit the diagram as a verbatim string literal: @"...". + // Quotes inside the diagram body are doubled. + sb.Append("@\""); + sb.Append(raw.Replace("\"", "\"\"")); + sb.Append('"'); + } +``` + +We use a verbatim `@"..."` string for maximum compatibility with `netstandard2.0` (no raw-literal support in older language versions). + +**Step 2: Wire it from `Write(StateMachineModel)`** + +In the main `Write` method (top of `StateMachineWriter`), just before the final `sb.AppendLine("}");`, call: + +```csharp + WriteMermaidDiagram(sb, model, /* resolver: */ null!); // placeholder; real resolver wired below +``` + +Actually we can't use `null!` because the writer might try to deref it. Better: have the entry-point accept the resolver as a param: + +```csharp +public static string Write(StateMachineModel model, + System.Func? resolveSubMachine = null) +``` + +Or simpler: just pass a static "always-null" lambda — `static _ => (StateMachineModel?)null` — when no resolver is supplied. The MermaidDiagramWriter handles a null return from the resolver (composite block isn't emitted; users see a flat reference). Decide once during implementation. + +For the generator path that has the `Compilation` available (i.e., `RegisterSourceOutput`), pass a closure that resolves FQN → `StateMachineModel?` via `StateMachineGenerator.BuildModelFromSymbol`. That closure needs a `Compilation` reference to do `compilation.GetTypeByMetadataName(fqn)`. + +Easiest threading: change the generator's `RegisterSourceOutput` callback signature so it has access to the `Compilation`. The current pipeline uses `models` directly. We need to combine `models` with `context.CompilationProvider`: + +```csharp +context.RegisterSourceOutput( + models.Combine(context.CompilationProvider), + static (ctx, tuple) => + { + var (model, compilation) = tuple; + // ... existing diagnostic reporting ... + + var resolver = (string fqn) => + { + var sym = compilation.GetTypeByMetadataName(fqn.Replace("global::", "")); + return sym is null ? null : StateMachineGenerator.BuildModelFromSymbol(sym); + }; + + var source = StateMachineWriter.Write(model, resolver); + // ... existing AddSource ... + }); +``` + +> **Caveat:** the `Combine` call breaks the incremental-cacheability of the pipeline (changing any file in the compilation invalidates the model). This is acceptable for the diagram case because the diagram is only emitted for `Diagram = true` classes; the cache penalty is paid only by those. Mention this in the commit body. + +**Step 3: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. Existing snapshots byte-identical (no fixtures use `Diagram = true`). + +**Step 4: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +git commit -m "$(cat <<'EOF' +feat(generator): emit MermaidDiagram const from StateMachineWriter when 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). +EOF +)" +``` + +--- + +## Task 11: Wire `MermaidDiagramWriter` into `StateMachineGroupWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs` +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` (parallel pipeline change) + +**Step 1: Emit `MermaidDiagram` from the group writer** + +In `StateMachineGroupWriter.Write(StateMachineGroupModel m)`, after the last per-part block but before the closing `}`: + +```csharp + if (m.Diagram) + { + var diagram = MermaidDiagramWriter.Write(m); + sb.AppendLine(); + sb.AppendLine($" /// Mermaid stateDiagram-v2 representation of this state-machine group."); + sb.Append($" public const string MermaidDiagram = "); + sb.Append("@\"").Append(diagram.Replace("\"", "\"\"")).Append('"'); + sb.AppendLine(";"); + } +``` + +Groups don't need a sub-machine resolver (parts can't contain composites — ZSM0018 enforces). + +**Step 2: No pipeline change for groups** + +The group pipeline doesn't need to combine with `CompilationProvider` because groups never render sub-FSMs. + +**Step 3: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. + +**Step 4: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs +git commit -m "$(cat <<'EOF' +feat(generator): emit MermaidDiagram const from StateMachineGroupWriter 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. +EOF +)" +``` + +--- + +## Task 12: Emit `ArmInitialStateTimers` helper in `StateMachineWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs` + +**Step 1: Add the helper emitter** + +Append: + +```csharp + private static void WriteArmInitialStateTimers(StringBuilder sb, StateMachineModel m) + { + if (!HasAnyTimedEdge(m)) return; + + var st = m.StateTypeFqn; + + sb.AppendLine(); + sb.AppendLine($" /// Arms timers for any timed edges whose From state matches the current state."); + sb.AppendLine($" private void ArmInitialStateTimers()"); + sb.AppendLine($" {{"); + sb.AppendLine($" var current = Current;"); + + foreach (var t in m.Transitions) + { + if (t.AfterMs == 0) continue; + if (t.Part is not null) continue; // group parts handled separately + + var field = $"_timer_{t.From}_{t.On}"; + sb.AppendLine($" if (current == {st}.{t.From})"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __t = {field};"); + sb.AppendLine($" if (__t is null)"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __new = new System.Threading.Timer("); + sb.AppendLine($" static s => (({m.ClassName})s!).TryFire({m.TriggerTypeFqn}.{t.On}),"); + sb.AppendLine($" this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" __t = System.Threading.Interlocked.CompareExchange(ref {field}, __new, null) ?? __new;"); + sb.AppendLine($" if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose();"); + sb.AppendLine($" }}"); + sb.AppendLine($" __t.Change({t.AfterMs}, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" }}"); + } + + sb.AppendLine($" }}"); + } +``` + +> **MA0051 note:** if the per-edge if-block emit pushes this over 60 lines (it shouldn't — each block is one method call), extract the inner per-edge emit into a `WriteArmBlockFor(StringBuilder, TransitionModel, string st, string tr, string className)` helper. + +**Step 2: Wire it from `WriteConcurrentBody`** + +In `WriteConcurrentBody`, after the existing `WriteDispose(sb, m);` (or wherever the concurrent body's tail block lives), append: + +```csharp + WriteArmInitialStateTimers(sb, m); +``` + +**Step 3: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass. (Existing snapshots don't have initial-state-armed timers — the Watchdog fixture starts in `Idle`, not `Working`. But the emit now includes a new `ArmInitialStateTimers` method on the Watchdog snapshot.) + +> **Snapshot drift WARNING:** This task introduces a new private method on every concurrent class with timed edges. The existing v1.4 snapshots (`TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs`, etc.) will now have the new method appended. Re-verify these snapshots — inspect the new content, confirm it's correct, then update the `.verified.cs` files. Document the regen in the commit. + +**Step 4: Update snapshots** + +```bash +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release --filter "FullyQualifiedName~TimedTransitionGeneratorTests" +# Inspect the new .received.cs files in Snapshots/, verify the ArmInitialStateTimers +# emit is correct, then rename .received.cs -> .verified.cs. +``` + +Same for `StateMachineGroupGeneratorTests.TwoPartsOneTimedEdge` if it changes (it shouldn't — Task 15 wires the group path). + +**Step 5: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs \ + tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/ +git commit -m "$(cat <<'EOF' +feat(generator): emit ArmInitialStateTimers helper in StateMachineWriter + +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. +EOF +)" +``` + +--- + +## Task 13: Emit `HookConstructor` partial + default ctor in `StateMachineWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs` +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` (detect user-declared ctor) + +**Step 1: Detect whether the user declared their own ctor** + +Add a new field to `StateMachineModel`: `bool HasUserCtor`. Update the record signature; update the call sites in `Parse` and `BuildModelFromSymbol` to detect: + +```csharp +var hasUserCtor = type.InstanceConstructors + .Any(c => !c.IsImplicitlyDeclared); +``` + +Pass it positionally into the constructor (right before `Diagnostics`). + +**Step 2: Emit the partial + default ctor** + +Append to `StateMachineWriter`: + +```csharp + private static void WriteHookConstructorAndCtor(StringBuilder sb, StateMachineModel m) + { + if (!HasAnyTimedEdge(m)) return; + + sb.AppendLine(); + sb.AppendLine($" /// Generator-emitted partial hook invoked from the constructor. Arms initial-state timers."); + sb.AppendLine($" private void HookConstructor()"); + sb.AppendLine($" {{"); + sb.AppendLine($" ArmInitialStateTimers();"); + sb.AppendLine($" }}"); + + if (!m.HasUserCtor) + { + sb.AppendLine(); + sb.AppendLine($" /// Default generator-emitted constructor; calls HookConstructor() to arm initial-state timers."); + sb.AppendLine($" public {m.ClassName}()"); + sb.AppendLine($" {{"); + sb.AppendLine($" HookConstructor();"); + sb.AppendLine($" }}"); + } + } +``` + +Wire it from `WriteConcurrentBody`, after `WriteArmInitialStateTimers(sb, m);`: + +```csharp + WriteHookConstructorAndCtor(sb, m); +``` + +**Step 3: Verify build + tests + snapshot regen** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +If any existing snapshot fails due to the new `HookConstructor()` + default ctor emit, inspect + rename `.received.cs` → `.verified.cs`. Document in the commit. + +**Step 4: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs \ + tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/ +git commit -m "$(cat <<'EOF' +feat(generator): emit HookConstructor + default ctor in StateMachineWriter + +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. +EOF +)" +``` + +--- + +## Task 14: Wire `Reset()` and `ResetTo(state)` to call `ArmInitialStateTimers` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs` + +**Step 1: Update `WriteResetMechanics`** + +Inside the `Reset()` body, after the existing `_state = InitialState;` line (and the composite sub-FSM resets), append: + +```csharp + if (HasAnyTimedEdge(m)) + { + sb.AppendLine($" ArmInitialStateTimers();"); + } +``` + +Same change inside the `ResetTo(state)` body, after the existing `_state = state;` assignment. + +**Step 2: Verify build + tests + snapshot regen** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Snapshot regen for the v1.4 timed-edge snapshots is expected. Inspect and rename. + +**Step 3: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs \ + tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/ +git commit -m "$(cat <<'EOF' +feat(generator): Reset() and ResetTo(state) now arm initial-state timers + +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. +EOF +)" +``` + +--- + +## Task 15: Emit per-part arm helpers + HookConstructor + group ctor in `StateMachineGroupWriter` + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs` +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs` (add `HasUserCtor`) +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` (detect user ctor in `ParseGroup`) + +**Step 1: Add `HasUserCtor` to `StateMachineGroupModel`** + +```csharp +internal sealed record StateMachineGroupModel( + string? Namespace, + string ClassName, + ImmutableArray Parts, + bool Diagram, + bool HasUserCtor, // NEW + ImmutableArray Diagnostics +); +``` + +Update `ParseGroup` to populate it (`type.InstanceConstructors.Any(c => !c.IsImplicitlyDeclared)`). + +**Step 2: Emit per-part `ArmInitialStateTimers_()` helpers** + +Inside `WritePartBody` (or after — your call), emit: + +```csharp + private static void WritePartArmInitialStateTimers(StringBuilder sb, string className, StateMachinePartModel p) + { + var hasTimed = p.Transitions.Any(static t => t.AfterMs > 0); + if (!hasTimed) return; + + var st = p.StateTypeFqn; + var tr = p.TriggerTypeFqn; + + sb.AppendLine(); + sb.AppendLine($" private void ArmInitialStateTimers_{p.Name}()"); + sb.AppendLine($" {{"); + sb.AppendLine($" var current = {p.Name}Current;"); + foreach (var t in p.Transitions) + { + if (t.AfterMs == 0) continue; + var field = $"_timer_{p.Name}_{t.From}_{t.On}"; + sb.AppendLine($" if (current == {st}.{t.From})"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __t = {field};"); + sb.AppendLine($" if (__t is null)"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __new = new System.Threading.Timer("); + sb.AppendLine($" static s => (({className})s!).TryFire{p.Name}({tr}.{t.On}),"); + sb.AppendLine($" this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" __t = System.Threading.Interlocked.CompareExchange(ref {field}, __new, null) ?? __new;"); + sb.AppendLine($" if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose();"); + sb.AppendLine($" }}"); + sb.AppendLine($" __t.Change({t.AfterMs}, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" }}"); + } + sb.AppendLine($" }}"); + } +``` + +Call from `WritePartBody` after `WritePartHooks`. + +**Step 3: Emit group-level `HookConstructor` and default ctor** + +Append a new method `WriteGroupHookAndCtor`: + +```csharp + private static void WriteGroupHookAndCtor(StringBuilder sb, StateMachineGroupModel m) + { + var anyTimed = m.Parts.Any(static p => p.Transitions.Any(static t => t.AfterMs > 0)); + if (!anyTimed) return; + + sb.AppendLine(); + sb.AppendLine($" /// Generator-emitted partial hook invoked from the constructor."); + sb.AppendLine($" private void HookConstructor()"); + sb.AppendLine($" {{"); + foreach (var p in m.Parts) + { + if (p.Transitions.Any(static t => t.AfterMs > 0)) + sb.AppendLine($" ArmInitialStateTimers_{p.Name}();"); + } + sb.AppendLine($" }}"); + + if (!m.HasUserCtor) + { + sb.AppendLine(); + sb.AppendLine($" public {m.ClassName}()"); + sb.AppendLine($" {{"); + sb.AppendLine($" HookConstructor();"); + sb.AppendLine($" }}"); + } + } +``` + +Wire from `Write(StateMachineGroupModel)`, just before the closing `}`: + +```csharp + WriteGroupHookAndCtor(sb, m); +``` + +**Step 4: Verify build + tests + snapshot regen** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +`TwoPartsOneTimedEdge` snapshot gains the new per-part arm helper + hook + default ctor. Inspect + rename. + +**Step 5: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs \ + src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs \ + tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/ +git commit -m "$(cat <<'EOF' +feat(generator): emit per-part ArmInitialStateTimers + group ctor in 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. +EOF +)" +``` + +--- + +## Task 16: Detect ZSM0021 (user-declared ctor without `HookConstructor()` invocation) + +**Files:** +- Modify: `src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs` + +**Step 1: Add the analyzer** + +Append: + +```csharp + private static void AnalyzeMissingHookConstructorInvocation( + INamedTypeSymbol type, + bool hasTimedEdges, + ImmutableArray.Builder diagnostics) + { + if (!hasTimedEdges) return; + + var userCtors = type.InstanceConstructors + .Where(c => !c.IsImplicitlyDeclared) + .ToArray(); + if (userCtors.Length == 0) return; + + var location = type.Locations.Length > 0 ? type.Locations[0] : Location.None; + + foreach (var ctor in userCtors) + { + if (CtorInvokesHookConstructor(ctor)) return; + } + + diagnostics.Add(Diagnostic.Create( + StateMachineDiagnostics.MissingHookConstructorInvocation, location, type.Name)); + } + + private static bool CtorInvokesHookConstructor(IMethodSymbol ctor) + { + foreach (var syntaxRef in ctor.DeclaringSyntaxReferences) + { + var node = syntaxRef.GetSyntax(); + if (node is null) continue; + + // Walk the ctor body's descendant invocations; look for HookConstructor(). + foreach (var inv in node.DescendantNodes().OfType()) + { + if (inv.Expression is Microsoft.CodeAnalysis.CSharp.Syntax.IdentifierNameSyntax id && + string.Equals(id.Identifier.ValueText, "HookConstructor", StringComparison.Ordinal)) + { + return true; + } + } + } + return false; + } +``` + +**Step 2: Wire from `AnalyzeDiagnostics`** (single-machine path) + +Append: + +```csharp + var hasTimed = transitions.Any(static t => t.AfterMs > 0); + AnalyzeMissingHookConstructorInvocation(type, hasTimed, diagnostics); +``` + +**Step 3: Wire from `AnalyzeGroupDiagnostics`** (group path) + +Same logic — `hasTimed = m.Parts.Any(p => p.Transitions.Any(t => t.AfterMs > 0))`. + +**Step 4: Verify build + tests** + +```bash +dotnet build src/ZeroAlloc.StateMachine.Generator/ZeroAlloc.StateMachine.Generator.csproj -c Release +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release +``` + +Expected: 32/32 + 26/26 pass (no fixture currently has both timed edges + user-declared ctor). + +**Step 5: Commit** + +```bash +git add src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +git commit -m "$(cat <<'EOF' +feat(generator): detect ZSM0021 (user-declared ctor must call HookConstructor) + +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. +EOF +)" +``` + +--- + +## Task 17: Snapshot tests for B4 (Mermaid diagram emit) + +**Files:** +- Create: `tests/ZeroAlloc.StateMachine.Generator.Tests/MermaidDiagramGeneratorTests.cs` +- Create (via verify): `Snapshots/MermaidDiagramGeneratorTests.Flat_Diagram#MyApp_Order.g.verified.cs` +- Create (via verify): `Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_Loading.g.verified.cs` + `*_LoadingFsm.g.verified.cs` +- Create (via verify): `Snapshots/MermaidDiagramGeneratorTests.Group_Diagram#MyApp_Device.Group.g.verified.cs` + +**Step 1: Add the test fixture** + +```csharp +namespace ZeroAlloc.StateMachine.Generator.Tests; + +using System.Threading.Tasks; +using VerifyXunit; +using Xunit; + +[UsesVerify] +public class MermaidDiagramGeneratorTests +{ + [Fact] + public Task Flat_Diagram() + { + const string source = @" +using ZeroAlloc.StateMachine; +namespace MyApp; + +public enum OS { Idle, Submitted, Shipped } +public enum OT { Submit, Ship, Cancel } + +[StateMachine(InitialState = ""Idle"", Diagram = true)] +[Transition(From = OS.Idle, On = OT.Submit, To = OS.Submitted)] +[Transition(From = OS.Submitted, On = OT.Ship, To = OS.Shipped, When = true)] +[Terminal(State = OS.Shipped)] +public partial class Order { } +"; + return TestHelper.Verify(source); + } + + [Fact] + public Task Composite_Diagram() + { + const string source = @" +using ZeroAlloc.StateMachine; +namespace MyApp; + +public enum LoadingState { Fetching, Parsing, Done } +public enum AppTrigger { Begin, Tick, Complete } + +[StateMachine(InitialState = ""Fetching"")] +[Transition(From = LoadingState.Fetching, On = AppTrigger.Tick, To = LoadingState.Parsing)] +[Transition(From = LoadingState.Parsing, On = AppTrigger.Complete, To = LoadingState.Done)] +[Terminal(State = LoadingState.Done)] +public partial class LoadingFsm { } + +public enum AppState { Idle, Loading, Ready } + +[StateMachine(InitialState = ""Idle"", Diagram = true)] +[Transition(From = AppState.Idle, On = AppTrigger.Begin, To = AppState.Loading)] +[Transition(From = AppState.Loading, On = AppTrigger.Complete, To = AppState.Ready)] +[CompositeState(State = AppState.Loading, SubMachine = typeof(LoadingFsm))] +[HistoryState(State = AppState.Loading)] +public partial class App { } +"; + return TestHelper.Verify(source); + } + + [Fact] + public Task Group_Diagram() + { + const string source = @" +using ZeroAlloc.StateMachine; +namespace MyApp; + +public enum OpS { Idle, Running } +public enum OpT { Start, Stop } +public enum ConnS { Disconnected, Connected } +public enum ConnT { Connect, Disconnect } + +[StateMachineGroup(Diagram = true)] +[StateMachinePart(Name = ""Op"", InitialState = OpS.Idle)] +[StateMachinePart(Name = ""Conn"", InitialState = ConnS.Disconnected)] +[Transition(From = OpS.Idle, On = OpT.Start, To = OpS.Running, Part = ""Op"")] +[Transition(From = OpS.Running, On = OpT.Stop, To = OpS.Idle, Part = ""Op"")] +[Transition(From = ConnS.Disconnected, On = ConnT.Connect, To = ConnS.Connected, Part = ""Conn"")] +[Transition(From = ConnS.Connected, On = ConnT.Disconnect, To = ConnS.Disconnected, Part = ""Conn"")] +public partial class Device { } +"; + return TestHelper.Verify(source); + } +} +``` + +**Step 2: Run; expect 3 received files** + +```bash +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release --filter "FullyQualifiedName~MermaidDiagramGeneratorTests" +``` + +Expected: 3 tests FAIL with "received but no verified" diff. Multiple `.received.cs` files (one per emitted source). + +**Step 3: Inspect each `.received.cs`** + +Verify each one: +- `Flat_Diagram` → `MermaidDiagram` const exists; contains `stateDiagram-v2`, `[*] --> Idle`, `Idle --> Submitted: Submit`, `Submitted --> Shipped: Ship [guard]`, `Shipped --> [*]`. +- `Composite_Diagram` → parent's diagram contains `state Loading {` block with `state H as History` and the sub-FSM's transitions; sub-FSM (`LoadingFsm`) does NOT have its own MermaidDiagram const (Diagram only set on App). +- `Group_Diagram` → contains two top-level `state Op { ... }` + `state Conn { ... }` blocks. + +**Step 4: Rename `.received.cs` → `.verified.cs`** + +```bash +cd tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots +# Rename each pair; the exact filenames depend on emitted hint names. +``` + +**Step 5: Re-run; expect 3 PASS** + +```bash +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release --filter "FullyQualifiedName~MermaidDiagramGeneratorTests" +``` + +Test count: was 32, now 35. + +**Step 6: Commit** + +```bash +git add tests/ZeroAlloc.StateMachine.Generator.Tests/MermaidDiagramGeneratorTests.cs \ + tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests*.verified.cs +git commit -m "$(cat <<'EOF' +test(generator): snapshot tests for Mermaid diagram emit (B4) + +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. +EOF +)" +``` + +--- + +## Task 18: Diagnostic tests for ZSM0020 + ZSM0021 + +**Files:** +- Modify: `tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs` + +**Step 1: Append the new tests** + +```csharp + [Fact] + public async Task ZSM0020_FiresWhen_Diagram_OnEmptyClass() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A } public enum T { Go } +[StateMachine(InitialState = ""A"", Diagram = true)] +public partial class M { } +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.Contains(diags, d => string.Equals(d.Id, ""ZSM0020"", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0020_FiresWhen_Diagram_OnEmptyGroup() + { + const string source = @" +using ZeroAlloc.StateMachine; +[StateMachineGroup(Diagram = true)] +public partial class M { } +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.Contains(diags, d => string.Equals(d.Id, ""ZSM0020"", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0021_FiresWhen_UserCtor_DoesNotCall_HookConstructor() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A, B } public enum T { Go } +[StateMachine(InitialState = ""A"", Concurrent = true)] +[Transition(From = S.A, On = T.Go, To = S.B, AfterMs = 1000)] +public partial class M +{ + public M(int x) { /* does NOT call HookConstructor */ } +} +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.Contains(diags, d => string.Equals(d.Id, ""ZSM0021"", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0021_DoesNotFire_When_UserCtor_Calls_HookConstructor() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A, B } public enum T { Go } +[StateMachine(InitialState = ""A"", Concurrent = true)] +[Transition(From = S.A, On = T.Go, To = S.B, AfterMs = 1000)] +public partial class M +{ + public M(int x) { HookConstructor(); } +} +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.DoesNotContain(diags, d => string.Equals(d.Id, ""ZSM0021"", StringComparison.Ordinal)); + } +``` + +(Use `string.Equals` with `StringComparison.Ordinal` per MA0006 conventions from the v1.4 Task 15 deviation.) + +**Step 2: Run** + +```bash +dotnet test tests/ZeroAlloc.StateMachine.Generator.Tests/ -c Release --filter "FullyQualifiedName~DiagnosticTests" +``` + +Expected: all 4 new tests PASS. Test count: was 35 (after Task 17), now 39. + +**Step 3: Commit** + +```bash +git add tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs +git commit -m "$(cat <<'EOF' +test(generator): diagnostic tests for ZSM0020 + ZSM0021 + + 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(). +EOF +)" +``` + +--- + +## Task 19: Runtime tests for initial-state arm + +**Files:** +- Create: `tests/ZeroAlloc.StateMachine.Tests/InitialStateArmTests.cs` + +**Step 1: Add the test fixture** + +```csharp +namespace ZeroAlloc.StateMachine.Tests; + +using System.Threading.Tasks; +using Xunit; +using ZeroAlloc.StateMachine; + +#pragma warning disable MA0048 // file holds multiple top-level types +#pragma warning disable ZSM0002 // sink states are intentional + +public enum WatchState { Working, Dead } +public enum WatchTrigger { Timeout } + +[StateMachine(InitialState = ""Working"", Concurrent = true)] +[Transition(From = WatchState.Working, On = WatchTrigger.Timeout, To = WatchState.Dead, AfterMs = 500)] +[Terminal(State = WatchState.Dead)] +public partial class InitialArmWatchdog { } + +public class InitialStateArmTests +{ + [Fact] + public async Task Constructor_arms_initial_state_timer() + { + using var w = new InitialArmWatchdog(); + Assert.Equal(WatchState.Working, w.Current); + + // Give the timer time to fire — no user TryFire call. + await Task.Delay(1000); + Assert.Equal(WatchState.Dead, w.Current); + } + + [Fact] + public async Task Reset_rearms_initial_state_timer() + { + using var w = new InitialArmWatchdog(); + await Task.Delay(1000); + Assert.Equal(WatchState.Dead, w.Current); + + // Reset puts state back to Working; should re-arm the timer. + // Reset is internal — access via reflection only if needed (or skip this test + // and add a [Fact(Skip = ...)] if internal access is too fiddly). + var resetMethod = typeof(InitialArmWatchdog).GetMethod(""Reset"", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(resetMethod); + resetMethod!.Invoke(w, null); + Assert.Equal(WatchState.Working, w.Current); + + await Task.Delay(1000); + Assert.Equal(WatchState.Dead, w.Current); + } +} +``` + +(`Reset` is `internal`, so the test reflects to invoke it. If that's awkward, mark the second test `[Fact(Skip = "Reset is internal; see snapshot test instead")]` and skip it.) + +**Step 2: Run** + +```bash +dotnet test tests/ZeroAlloc.StateMachine.Tests/ -c Release --filter "FullyQualifiedName~InitialStateArmTests" +``` + +Expected: both PASS. Runtime test count: was 26 (after v1.4), now 28. + +**Step 3: Commit** + +```bash +git add tests/ZeroAlloc.StateMachine.Tests/InitialStateArmTests.cs +git commit -m "$(cat <<'EOF' +test: runtime tests for initial-state arm (closes v1.4 caveat) + +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. +EOF +)" +``` + +--- + +## Task 20: Documentation updates + +**Files:** +- Modify: `docs/core-concepts/timeout-transitions.md` +- Create: `docs/core-concepts/diagram-export.md` +- Create: `docs/diagnostics/ZSM0020.md`, `docs/diagnostics/ZSM0021.md` +- Modify: `docs/attributes.md` +- Modify: `docs/index.md` + +**Step 1: Update `timeout-transitions.md`** + +Remove the "Caveats" section entirely (the gap is closed). Update the body where it describes when timers arm to read "armed on construction, on every entry into the source state, and on `Reset()` / `ResetTo(state)`". + +**Step 2: Create `docs/core-concepts/diagram-export.md`** + +Follow the existing core-concepts template (frontmatter `id`/`title`/`sidebar_position`, intro, sections, "Related" footer at bottom). Cover: +- What `Diagram = true` does. +- Where the diagram lands (`public const string MermaidDiagram`). +- A short example with rendered Mermaid output as a fenced block. +- Notes on what's rendered (composites, history, groups, timed annotations, guards, terminals). +- Reference ZSM0020. + +**Step 3: Create `docs/diagnostics/ZSM0020.md` + `ZSM0021.md`** + +Follow the existing diagnostics template — see `docs/diagnostics/ZSM0019.md` for the closest reference. Each has Severity, Example (triggering source), How-to-fix (resolution source). + +**Step 4: Update `docs/attributes.md`** + +Add a row to `[StateMachine]`'s properties table for `Diagram` (link to `core-concepts/diagram-export.md`). Same for `[StateMachineGroup]`. Both new properties: `bool`, default `false`. + +**Step 5: Update `docs/index.md`** + +Add entries to the top-level table of contents: +- Core concepts → `diagram-export.md`. +- Diagnostics → `ZSM0020.md`, `ZSM0021.md`. + +**Step 6: Commit** + +```bash +git add docs/ +git commit -m "$(cat <<'EOF' +docs: diagram export (B4) + initial-arm closes v1.4 caveat + + - 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. +EOF +)" +``` + +--- + +## Task 21: Push branch + open PR + merge when green + +**Files:** (no source — git + gh) + +**Step 1: Final whole-repo build + test** + +```bash +dotnet build -c Release +dotnet test -c Release +``` + +Expected: 0 errors, all warnings pre-existing (the same MSB3277 + MA0048 ones on `benchmarks/`). Tests: 39/39 generator + 28/28 runtime. + +**Step 2: Push the branch** + +```bash +git push -u origin feat/mermaid-export-and-initial-arm +``` + +**Step 3: Open the PR** + +```bash +gh pr create --title "feat: Mermaid diagram export (B4) + initial-state arm fix" --body "$(cat <<'EOF' +## 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 with `(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 a `partial void HookConstructor()` + a default ctor when the user hasn't declared one; ZSM0021 fires if a user-declared ctor doesn't invoke `HookConstructor()`. + +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 + +- [x] Generator snapshot tests for Mermaid emit (flat / composite / group) +- [x] Diagnostic tests for ZSM0020 (positive: empty class) + ZSM0021 (positive + negative) +- [x] Runtime tests for initial-state arm (ctor + Reset) +- [x] All v1.4 tests still pass (32 → 39 generator, 26 → 28 runtime) +- [x] Existing v1.4 snapshots regenerated to include the new `ArmInitialStateTimers` + `HookConstructor` + ctor emit; visually inspected + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +**Step 4: Wait for CI** + +```bash +until [ -z "$(gh pr checks --jq '.[] | select(.state == "PENDING")' 2>&1 | head -1)" ]; do sleep 30; done +gh pr checks +``` + +Expected: all green. + +**Step 5: Merge with admin (matches PR #27's pattern; user authorized in advance)** + +```bash +gh pr merge --squash --delete-branch --admin +``` + +**Step 6: Sync local main** + +```bash +git checkout main && git fetch origin main && git reset --hard origin/main +``` + +--- + +## Notes for the implementer + +- **MA0051 (60-line method limit).** Most new methods are emitter helpers; if any starts pushing 60 lines, split it. The Mermaid writer's `WriteIndented` is the most likely candidate. +- **RS1032 (CodeAnalysis message style).** Two new descriptors. Watch for interior periods + trailing period requirement. +- **MA0006 (string.Equals vs ==).** Used consistently in new code per the v1.4 Task 15 deviation. +- **PublicAPI tracking.** Four new lines for `Diagram` on the two attributes. RS0016/RS0017 will tell you if you missed any. +- **VerifyXunit snapshots.** Task 12, 13, 14 regenerate existing v1.4 snapshots because the emit shape now includes ctor/hook/arm helpers. Inspect each diff and rename `.received.cs` → `.verified.cs` only after confirming correctness. +- **Cross-class composite rendering** (Task 7) goes through `BuildModelFromSymbol` on metadata-only symbols — confirmed working for in-assembly sub-FSMs; cross-assembly should work but isn't covered by tests in this PR (defer until a real consumer asks). +- **`Combine` with CompilationProvider** (Task 10) partially invalidates incremental caching for the diagram pipeline. Acceptable trade-off; only `Diagram = true` users pay it. From a61ab32636559dc18a2e69bd7beeec6914598ba0 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 08:54:30 +0200 Subject: [PATCH 03/23] feat: add Diagram property to [StateMachine] and [StateMachineGroup] (B4 runtime surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt | 4 ++++ src/ZeroAlloc.StateMachine/StateMachineAttribute.cs | 10 ++++++++++ .../StateMachineGroupAttribute.cs | 8 ++++++++ 3 files changed, 22 insertions(+) diff --git a/src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt b/src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt index a62cf24..81fdc97 100644 --- a/src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt +++ b/src/ZeroAlloc.StateMachine/PublicAPI.Unshipped.txt @@ -12,10 +12,14 @@ ZeroAlloc.StateMachine.HistoryStateAttribute.State.init -> void ZeroAlloc.StateMachine.StateMachineAttribute ZeroAlloc.StateMachine.StateMachineAttribute.Concurrent.get -> bool ZeroAlloc.StateMachine.StateMachineAttribute.Concurrent.init -> void +ZeroAlloc.StateMachine.StateMachineAttribute.Diagram.get -> bool +ZeroAlloc.StateMachine.StateMachineAttribute.Diagram.init -> void ZeroAlloc.StateMachine.StateMachineAttribute.InitialState.get -> string! ZeroAlloc.StateMachine.StateMachineAttribute.InitialState.init -> void ZeroAlloc.StateMachine.StateMachineAttribute.StateMachineAttribute() -> void ZeroAlloc.StateMachine.StateMachineGroupAttribute +ZeroAlloc.StateMachine.StateMachineGroupAttribute.Diagram.get -> bool +ZeroAlloc.StateMachine.StateMachineGroupAttribute.Diagram.init -> void ZeroAlloc.StateMachine.StateMachineGroupAttribute.StateMachineGroupAttribute() -> void ZeroAlloc.StateMachine.StateMachinePartAttribute ZeroAlloc.StateMachine.StateMachinePartAttribute.InitialState.get -> TState diff --git a/src/ZeroAlloc.StateMachine/StateMachineAttribute.cs b/src/ZeroAlloc.StateMachine/StateMachineAttribute.cs index f132303..73fc285 100644 --- a/src/ZeroAlloc.StateMachine/StateMachineAttribute.cs +++ b/src/ZeroAlloc.StateMachine/StateMachineAttribute.cs @@ -27,4 +27,14 @@ public sealed class StateMachineAttribute : Attribute /// Default: false. /// public bool Concurrent { get; init; } = false; + + /// + /// When true, the generator emits a public const string MermaidDiagram + /// on the partial containing a Mermaid stateDiagram-v2 rendering of the + /// machine's transitions. Composite sub-FSMs render as nested state X { ... } + /// blocks; timed edges annotate with (after Nms); guards annotate with + /// [guard]; terminal states render as X --> [*]. + /// Default: false. + /// + public bool Diagram { get; init; } = false; } diff --git a/src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs b/src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs index b5ce672..73e2f20 100644 --- a/src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs +++ b/src/ZeroAlloc.StateMachine/StateMachineGroupAttribute.cs @@ -16,4 +16,12 @@ namespace ZeroAlloc.StateMachine; [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public sealed class StateMachineGroupAttribute : Attribute { + /// + /// When true, the generator emits a public const string MermaidDiagram + /// on the group partial. Each + /// renders as a top-level state {Name} { ... } block; transitions, terminals, + /// timed edges, and guards render per the standard Mermaid rules. + /// Default: false. + /// + public bool Diagram { get; init; } = false; } From e075f03cead8ebf754805a4e91344e74c64aca52 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 08:57:38 +0200 Subject: [PATCH 04/23] Add ZSM0020 (EmptyDiagramRequest) and ZSM0021 (MissingHookConstructorInvocation) 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) --- .../StateMachineDiagnostics.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs index b185b73..73341b0 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineDiagnostics.cs @@ -174,4 +174,22 @@ internal static class StateMachineDiagnostics defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, description: "When timed transitions exist, the generator emits public void Dispose() implementing IDisposable; a user method with a different signature collides with it."); + + public static readonly DiagnosticDescriptor EmptyDiagramRequest = new( + id: "ZSM0020", + title: "[StateMachine(Diagram = true)] on a class with zero transitions", + messageFormat: "'{0}' declares Diagram = true but has no transitions; the emitted MermaidDiagram would be empty", + category: "ZeroAlloc.StateMachine", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Either remove Diagram = true or add at least one [Transition] (or [StateMachinePart] with its own transitions for a group)."); + + public static readonly DiagnosticDescriptor MissingHookConstructorInvocation = new( + id: "ZSM0021", + title: "User-declared constructor must call HookConstructor()", + messageFormat: "'{0}' has at least one timed transition AND a user-declared constructor that does not invoke HookConstructor(). Add a HookConstructor() call so the generator can arm initial-state timers.", + category: "ZeroAlloc.StateMachine", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "When timed transitions are present and the user declares their own constructor, that constructor must call the generator-emitted partial void HookConstructor() to arm initial-state timers."); } From fc5547022e46e74b36d90259b85b030688266307 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:00:46 +0200 Subject: [PATCH 05/23] feat(generator): extend models with Diagram flag 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) --- .../StateMachineGenerator.cs | 5 ++++- .../StateMachineGroupModel.cs | 1 + src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 005f34f..2c5ad73 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -87,7 +87,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) AnalyzeGroupDiagnostics(type, parts, diagnostics); return new StateMachineGroupModel( - ns, type.Name, parts, diagnostics.ToImmutable()); + ns, type.Name, parts, + Diagram: false, + diagnostics.ToImmutable()); } private readonly record struct PartDeclaration( @@ -311,6 +313,7 @@ private static void AnalyzeCompositeInGroup( triggerTypeFqn, triggerTypeShort!, transitions, terminalStates, compositeStates, historyStates, + Diagram: false, diagnostics.ToImmutable()); } diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs index 98b281c..113c33d 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs @@ -8,5 +8,6 @@ internal sealed record StateMachineGroupModel( string? Namespace, string ClassName, ImmutableArray Parts, + bool Diagram, ImmutableArray Diagnostics ); diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs index 0819b35..804d0d6 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs @@ -18,5 +18,6 @@ internal sealed record StateMachineModel( ImmutableArray TerminalStates, // short enum member names ImmutableArray CompositeStates, ImmutableArray HistoryStates, + bool Diagram, ImmutableArray Diagnostics ); From a322f92c7b9ec969d48c7a7b47b3adec716c7e86 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:05:16 +0200 Subject: [PATCH 06/23] feat(generator): parse Diagram named arg on [StateMachine] + [StateMachineGroup] 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) --- .../StateMachineGenerator.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 2c5ad73..4445ad2 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -81,6 +81,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context) : type.ContainingNamespace.ToDisplayString(); var diagnostics = ImmutableArray.CreateBuilder(); + var groupAttr = ctx.Attributes[0]; + var diagram = groupAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; + var parts = CollectGroupParts(type); ct.ThrowIfCancellationRequested(); @@ -88,7 +92,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return new StateMachineGroupModel( ns, type.Name, parts, - Diagram: false, + Diagram: diagram, diagnostics.ToImmutable()); } @@ -285,6 +289,8 @@ private static void AnalyzeCompositeInGroup( .FirstOrDefault(kv => string.Equals(kv.Key, "InitialState", StringComparison.Ordinal)).Value.Value as string ?? string.Empty; var concurrent = smAttr.NamedArguments .FirstOrDefault(kv => string.Equals(kv.Key, "Concurrent", StringComparison.Ordinal)).Value.Value is true; + var diagram = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; var (transitions, terminalStates, compositeStates, historyStates, stateTypeFqn, stateTypeShort, triggerTypeFqn, triggerTypeShort) @@ -313,7 +319,7 @@ private static void AnalyzeCompositeInGroup( triggerTypeFqn, triggerTypeShort!, transitions, terminalStates, compositeStates, historyStates, - Diagram: false, + Diagram: diagram, diagnostics.ToImmutable()); } From 8d5d76a15698b0aaeb03a6bfef2b970e1c5fd83c Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:10:11 +0200 Subject: [PATCH 07/23] feat(generator): detect ZSM0020 (empty Diagram request) 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) --- .../StateMachineGenerator.cs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 4445ad2..12e5fa0 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -40,6 +40,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (model.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error)) return; + // Skip emit when there are no transitions — the model was only built so that + // AnalyzeDiagnostics could fire ZSM0020 (Diagram = true on an empty machine). + if (model.Transitions.IsEmpty) + return; + var source = StateMachineWriter.Write(model); var hintName = model.Namespace is null ? $"{model.ClassName}.g.cs" @@ -88,7 +93,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var parts = CollectGroupParts(type); ct.ThrowIfCancellationRequested(); - AnalyzeGroupDiagnostics(type, parts, diagnostics); + AnalyzeGroupDiagnostics(type, parts, diagram, diagnostics); return new StateMachineGroupModel( ns, type.Name, parts, @@ -184,6 +189,7 @@ private static ImmutableArray CollectPartDeclarations(INamedTyp private static void AnalyzeGroupDiagnostics( INamedTypeSymbol type, ImmutableArray parts, + bool diagram, ImmutableArray.Builder diagnostics) { var location = type.Locations.Length > 0 ? type.Locations[0] : Location.None; @@ -193,6 +199,13 @@ private static void AnalyzeGroupDiagnostics( AnalyzeDuplicatePartNames(type, parts, location, diagnostics); AnalyzeUnknownTransitionParts(type, parts, location, diagnostics); AnalyzeCompositeInGroup(type, location, diagnostics); + + var anyTransition = parts.Any(static p => !p.Transitions.IsEmpty); + if (diagram && !anyTransition) + { + diagnostics.Add(Diagnostic.Create( + StateMachineDiagnostics.EmptyDiagramRequest, location, type.Name)); + } } // ZSM0014: [StateMachine] and [StateMachineGroup] on the same class @@ -296,8 +309,11 @@ private static void AnalyzeCompositeInGroup( stateTypeFqn, stateTypeShort, triggerTypeFqn, triggerTypeShort) = CollectAttributes(type); - if (transitions.IsEmpty) return null; // No transitions found — not a valid state machine - if (stateTypeFqn is null || triggerTypeFqn is null) return null; + // If there are no transitions, normally skip — but if Diagram = true, we still + // want AnalyzeDiagnostics to fire ZSM0020. The RegisterSourceOutput callback + // short-circuits on empty transitions so the writer is never invoked. + if (transitions.IsEmpty && !diagram) return null; + if (!transitions.IsEmpty && (stateTypeFqn is null || triggerTypeFqn is null)) return null; if (string.IsNullOrEmpty(initialState)) return null; var ns = type.ContainingNamespace.IsGlobalNamespace @@ -309,14 +325,18 @@ private static void AnalyzeCompositeInGroup( ct.ThrowIfCancellationRequested(); AnalyzeDiagnostics(initialState, transitions, terminalStates, compositeStates, historyStates, - stateTypeShort!, triggerTypeFqn!, triggerTypeShort!, - type, isStruct, concurrent, diagnostics); + stateTypeShort ?? string.Empty, + triggerTypeFqn ?? string.Empty, + triggerTypeShort ?? string.Empty, + type, isStruct, concurrent, diagram, diagnostics); return new StateMachineModel( ns, type.Name, isStruct, initialState, concurrent, - stateTypeFqn, stateTypeShort!, - triggerTypeFqn, triggerTypeShort!, + stateTypeFqn ?? string.Empty, + stateTypeShort ?? string.Empty, + triggerTypeFqn ?? string.Empty, + triggerTypeShort ?? string.Empty, transitions, terminalStates, compositeStates, historyStates, Diagram: diagram, @@ -484,6 +504,7 @@ private static void AnalyzeDiagnostics( INamedTypeSymbol type, bool isStruct, bool concurrent, + bool diagram, ImmutableArray.Builder diagnostics) { var location = type.Locations.Length > 0 ? type.Locations[0] : Location.None; @@ -513,6 +534,21 @@ private static void AnalyzeDiagnostics( stateTypeShort, triggerTypeFqn, triggerTypeShort, type, concurrent, diagnostics); AnalyzeTimedTransitions(transitions, stateTypeShort, type, concurrent, diagnostics); AnalyzeDisposeConflict(type, transitions, diagnostics); + AnalyzeEmptyDiagramRequest(diagram, transitions, type, diagnostics); + } + + private static void AnalyzeEmptyDiagramRequest( + bool diagram, + ImmutableArray transitions, + INamedTypeSymbol type, + ImmutableArray.Builder diagnostics) + { + if (!diagram) return; + if (!transitions.IsEmpty) return; + + var location = type.Locations.Length > 0 ? type.Locations[0] : Location.None; + diagnostics.Add(Diagnostic.Create( + StateMachineDiagnostics.EmptyDiagramRequest, location, type.Name)); } private static void AnalyzeReachability( From 5a5750a52226044c9248a3c22338a3bc201d2c79 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:17:18 +0200 Subject: [PATCH 08/23] feat(generator): add MermaidDiagramWriter for flat-machine rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../MermaidDiagramWriter.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs diff --git a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs new file mode 100644 index 0000000..c328b8f --- /dev/null +++ b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs @@ -0,0 +1,47 @@ +namespace ZeroAlloc.StateMachine.Generator; + +using System.Linq; +using System.Text; + +/// +/// Emits Mermaid stateDiagram-v2 body content from a or +/// . The output is the diagram body only — callers wrap +/// it in a public const string MermaidDiagram = "..." literal in the generated partial. +/// +internal static class MermaidDiagramWriter +{ + /// Emit a Mermaid stateDiagram-v2 body for a single-machine model. + public static string Write(StateMachineModel m) + { + var sb = new StringBuilder(); + sb.AppendLine("stateDiagram-v2"); + + WriteIndented(sb, m, indent: " "); + + return sb.ToString().TrimEnd(); + } + + private static void WriteIndented(StringBuilder sb, StateMachineModel m, string indent) + { + // Initial-state marker. + sb.Append(indent).Append("[*] --> ").AppendLine(m.InitialState); + + // Transitions. + foreach (var t in m.Transitions) + { + sb.Append(indent); + sb.Append(t.From).Append(" --> ").Append(t.To).Append(": ").Append(t.On); + if (t.AfterMs > 0) + sb.Append(" (after ").Append(t.AfterMs).Append("ms)"); + if (t.HasGuard) + sb.Append(" [guard]"); + sb.AppendLine(); + } + + // Terminal states. + foreach (var s in m.TerminalStates) + { + sb.Append(indent).Append(s).AppendLine(" --> [*]"); + } + } +} From 5ac0b3090fb5e8eb8cba54accff110273bddaf89 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:22:25 +0200 Subject: [PATCH 09/23] feat(generator): render composite states as nested state blocks in MermaidDiagramWriter 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) --- .../MermaidDiagramWriter.cs | 39 +++++++++++++-- .../StateMachineGenerator.cs | 48 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs index c328b8f..5539907 100644 --- a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs @@ -11,22 +11,34 @@ namespace ZeroAlloc.StateMachine.Generator; internal static class MermaidDiagramWriter { /// Emit a Mermaid stateDiagram-v2 body for a single-machine model. - public static string Write(StateMachineModel m) + /// The state-machine model to render. + /// + /// Resolver invoked for each composite state's . + /// Returns a for the sub-FSM, or null to skip that + /// composite. May be null to render the parent without expanding any composites. + /// + public static string Write(StateMachineModel m, System.Func? resolveSubMachine) { var sb = new StringBuilder(); sb.AppendLine("stateDiagram-v2"); - WriteIndented(sb, m, indent: " "); + WriteIndented(sb, m, indent: " ", resolveSubMachine); return sb.ToString().TrimEnd(); } - private static void WriteIndented(StringBuilder sb, StateMachineModel m, string indent) + private static void WriteIndented(StringBuilder sb, StateMachineModel m, string indent, System.Func? resolveSubMachine) { // Initial-state marker. sb.Append(indent).Append("[*] --> ").AppendLine(m.InitialState); - // Transitions. + WriteTransitions(sb, m, indent); + WriteTerminals(sb, m, indent); + WriteComposites(sb, m, indent, resolveSubMachine); + } + + private static void WriteTransitions(StringBuilder sb, StateMachineModel m, string indent) + { foreach (var t in m.Transitions) { sb.Append(indent); @@ -37,11 +49,28 @@ private static void WriteIndented(StringBuilder sb, StateMachineModel m, string sb.Append(" [guard]"); sb.AppendLine(); } + } - // Terminal states. + private static void WriteTerminals(StringBuilder sb, StateMachineModel m, string indent) + { foreach (var s in m.TerminalStates) { sb.Append(indent).Append(s).AppendLine(" --> [*]"); } } + + private static void WriteComposites(StringBuilder sb, StateMachineModel m, string indent, System.Func? resolveSubMachine) + { + if (resolveSubMachine is null) return; + + foreach (var c in m.CompositeStates) + { + var subModel = resolveSubMachine(c.SubMachineFqn); + if (subModel is null) continue; + + sb.Append(indent).Append("state ").Append(c.State).AppendLine(" {"); + WriteIndented(sb, subModel, indent + " ", resolveSubMachine); + sb.Append(indent).AppendLine("}"); + } + } } diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 12e5fa0..c637107 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -291,6 +291,54 @@ private static void AnalyzeCompositeInGroup( } } + /// + /// Build a from a raw without + /// going through . Used by the Mermaid diagram + /// writer to resolve sub-FSM types referenced by composite states at parent-emit time. + /// + /// + /// This does not run diagnostic analysis — the returned model's + /// is always empty. Sub-FSMs that lack the + /// [StateMachine] attribute, lack transitions, or lack a resolvable initial state + /// return null. + /// + internal static StateMachineModel? BuildModelFromSymbol(INamedTypeSymbol type) + { + var smAttr = type.GetAttributes() + .FirstOrDefault(a => string.Equals(a.AttributeClass?.MetadataName, StateMachineAttributeMetadataName, StringComparison.Ordinal)); + if (smAttr is null) return null; + + var initialState = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "InitialState", StringComparison.Ordinal)).Value.Value as string ?? string.Empty; + var concurrent = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Concurrent", StringComparison.Ordinal)).Value.Value is true; + var diagram = smAttr.NamedArguments + .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; + + var (transitions, terminalStates, compositeStates, historyStates, + stateTypeFqn, stateTypeShort, triggerTypeFqn, triggerTypeShort) + = CollectAttributes(type); + + if (transitions.IsEmpty) return null; + if (stateTypeFqn is null || triggerTypeFqn is null) return null; + if (string.IsNullOrEmpty(initialState)) return null; + + var ns = type.ContainingNamespace.IsGlobalNamespace + ? null + : type.ContainingNamespace.ToDisplayString(); + var isStruct = type.TypeKind == TypeKind.Struct; + + return new StateMachineModel( + ns, type.Name, isStruct, + initialState, concurrent, + stateTypeFqn, stateTypeShort!, + triggerTypeFqn, triggerTypeShort!, + transitions, terminalStates, + compositeStates, historyStates, + Diagram: diagram, + Diagnostics: ImmutableArray.Empty); + } + private static StateMachineModel? Parse(GeneratorAttributeSyntaxContext ctx, CancellationToken ct) { if (ctx.TargetSymbol is not INamedTypeSymbol type) return null; From 1ee34ac897141cd6cc5601c73dad8da443d228f3 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:25:18 +0200 Subject: [PATCH 10/23] feat(generator): render shallow history pseudo-state inside composite 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) --- .../MermaidDiagramWriter.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs index 5539907..b34bc11 100644 --- a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs @@ -69,6 +69,13 @@ private static void WriteComposites(StringBuilder sb, StateMachineModel m, strin if (subModel is null) continue; sb.Append(indent).Append("state ").Append(c.State).AppendLine(" {"); + + var hasHistory = m.HistoryStates.Any(h => string.Equals(h.State, c.State, System.StringComparison.Ordinal)); + if (hasHistory) + { + sb.Append(indent).AppendLine(" state H as History"); + } + WriteIndented(sb, subModel, indent + " ", resolveSubMachine); sb.Append(indent).AppendLine("}"); } From 6d8b023c310e19d0a752ac066fb0d726878f0c41 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:27:43 +0200 Subject: [PATCH 11/23] feat(generator): render [StateMachineGroup] as top-level state blocks 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) --- .../MermaidDiagramWriter.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs index b34bc11..5b51a44 100644 --- a/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/MermaidDiagramWriter.cs @@ -59,6 +59,42 @@ private static void WriteTerminals(StringBuilder sb, StateMachineModel m, string } } + /// Emit a Mermaid stateDiagram-v2 body for a state-machine group model. + /// The group model to render. + public static string Write(StateMachineGroupModel m) + { + var sb = new StringBuilder(); + sb.AppendLine("stateDiagram-v2"); + + foreach (var p in m.Parts) + { + sb.Append(" state ").Append(p.Name).AppendLine(" {"); + WritePart(sb, p, indent: " "); + sb.AppendLine(" }"); + } + + return sb.ToString().TrimEnd(); + } + + private static void WritePart(StringBuilder sb, StateMachinePartModel p, string indent) + { + sb.Append(indent).Append("[*] --> ").AppendLine(p.InitialState); + + foreach (var t in p.Transitions) + { + sb.Append(indent); + sb.Append(t.From).Append(" --> ").Append(t.To).Append(": ").Append(t.On); + if (t.AfterMs > 0) + sb.Append(" (after ").Append(t.AfterMs).Append("ms)"); + if (t.HasGuard) + sb.Append(" [guard]"); + sb.AppendLine(); + } + + // Groups never have composites or history (ZSM0018 blocks composites in groups); + // groups also don't have a TerminalStates field on the part model. + } + private static void WriteComposites(StringBuilder sb, StateMachineModel m, string indent, System.Func? resolveSubMachine) { if (resolveSubMachine is null) return; From 4df3df68f18e0abe4954b7f1d9cccc3d16539e54 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:33:08 +0200 Subject: [PATCH 12/23] feat(generator): emit MermaidDiagram const from StateMachineWriter when 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) --- .../StateMachineGenerator.cs | 51 +++++++++++-------- .../StateMachineWriter.cs | 26 +++++++++- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index c637107..6384755 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -31,26 +31,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static m => m is not null) .Select(static (m, _) => m!); - context.RegisterSourceOutput(models, static (ctx, model) => - { - foreach (var diag in model.Diagnostics) - ctx.ReportDiagnostic(diag); - - // Do not emit source if any diagnostic is a hard error — the model is invalid - if (model.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error)) - return; - - // Skip emit when there are no transitions — the model was only built so that - // AnalyzeDiagnostics could fire ZSM0020 (Diagram = true on an empty machine). - if (model.Transitions.IsEmpty) - return; - - var source = StateMachineWriter.Write(model); - var hintName = model.Namespace is null - ? $"{model.ClassName}.g.cs" - : $"{model.Namespace}_{model.ClassName}.g.cs"; - ctx.AddSource(hintName, source); - }); + context.RegisterSourceOutput( + models.Combine(context.CompilationProvider), + static (ctx, tuple) => EmitStateMachine(ctx, tuple.Left, tuple.Right)); var groupModels = context.SyntaxProvider .ForAttributeWithMetadataName( @@ -76,6 +59,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } + private static void EmitStateMachine(SourceProductionContext ctx, StateMachineModel model, Compilation compilation) + { + foreach (var diag in model.Diagnostics) + ctx.ReportDiagnostic(diag); + + // Do not emit source if any diagnostic is a hard error — the model is invalid + if (model.Diagnostics.Any(static d => d.Severity == DiagnosticSeverity.Error)) + return; + + // Skip emit when there are no transitions — the model was only built so that + // AnalyzeDiagnostics could fire ZSM0020 (Diagram = true on an empty machine). + if (model.Transitions.IsEmpty) + return; + + System.Func resolver = fqn => + { + var clean = fqn.StartsWith("global::", StringComparison.Ordinal) ? fqn.Substring(8) : fqn; + var sym = compilation.GetTypeByMetadataName(clean); + return sym is null ? null : BuildModelFromSymbol(sym); + }; + + var source = StateMachineWriter.Write(model, resolver); + var hintName = model.Namespace is null + ? $"{model.ClassName}.g.cs" + : $"{model.Namespace}_{model.ClassName}.g.cs"; + ctx.AddSource(hintName, source); + } + private static StateMachineGroupModel? ParseGroup(GeneratorAttributeSyntaxContext ctx, CancellationToken ct) { if (ctx.TargetSymbol is not INamedTypeSymbol type) return null; diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs index 3ca17ac..ec46dca 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs @@ -5,7 +5,8 @@ namespace ZeroAlloc.StateMachine.Generator; internal static class StateMachineWriter { - public static string Write(StateMachineModel model) + public static string Write(StateMachineModel model, + System.Func? resolveSubMachine = null) { var sb = new StringBuilder(); sb.AppendLine("// "); @@ -28,10 +29,33 @@ public static string Write(StateMachineModel model) else WriteNonConcurrentBody(sb, model); + WriteMermaidDiagram(sb, model, resolveSubMachine ?? (static _ => null)); + sb.AppendLine("}"); return sb.ToString(); } + private static void WriteMermaidDiagram(StringBuilder sb, StateMachineModel m, + System.Func resolveSubMachine) + { + if (!m.Diagram) return; + + var diagram = MermaidDiagramWriter.Write(m, resolveSubMachine); + sb.AppendLine(); + sb.AppendLine($" /// Mermaid stateDiagram-v2 representation of this state machine."); + sb.Append($" public const string MermaidDiagram = "); + AppendQuotedMultiline(sb, diagram); + sb.AppendLine(";"); + } + + private static void AppendQuotedMultiline(StringBuilder sb, string raw) + { + // Verbatim string literal: @"...". Quotes inside doubled. + sb.Append("@\""); + sb.Append(raw.Replace("\"", "\"\"")); + sb.Append('"'); + } + // ── Non-concurrent ──────────────────────────────────────────────────────── private static void WriteNonConcurrentBody(StringBuilder sb, StateMachineModel m) From 50268aa3fef28b5f45aefe4c5d74737aa8ec67c7 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:36:55 +0200 Subject: [PATCH 13/23] feat(generator): emit MermaidDiagram const from StateMachineGroupWriter 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) --- .../StateMachineGroupWriter.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs index 104f159..83ae8e1 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs @@ -31,6 +31,16 @@ public static string Write(StateMachineGroupModel m) if (hasAnyTimer) WriteGroupDispose(sb, m); + if (m.Diagram) + { + var diagram = MermaidDiagramWriter.Write(m); + sb.AppendLine(); + sb.AppendLine($" /// Mermaid stateDiagram-v2 representation of this state-machine group."); + sb.Append($" public const string MermaidDiagram = "); + sb.Append("@\"").Append(diagram.Replace("\"", "\"\"")).Append('"'); + sb.AppendLine(";"); + } + sb.AppendLine("}"); return sb.ToString(); } From ada0ef19dc25112c213971e4047d324bc4b75bd5 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:42:18 +0200 Subject: [PATCH 14/23] feat(generator): emit ArmInitialStateTimers helper in StateMachineWriter 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) --- .../StateMachineWriter.cs | 38 +++++++++++++++++++ ...ltipleTimedEdges#MyApp_Multi.g.verified.cs | 32 ++++++++++++++++ ...ngleTimedEdge#MyApp_Watchdog.g.verified.cs | 19 ++++++++++ 3 files changed, 89 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs index ec46dca..146f900 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs @@ -353,6 +353,8 @@ private static void WriteConcurrentBody(StringBuilder sb, StateMachineModel m) WriteConcurrentPartialStubs(sb, m); WriteDispose(sb, m); + + WriteArmInitialStateTimers(sb, m); } private static void WriteConcurrentTryFire(StringBuilder sb, StateMachineModel m) @@ -529,6 +531,42 @@ private static void WriteTimerDisarmBlocks(StringBuilder sb, StateMachineModel m private static bool HasAnyTimedEdge(StateMachineModel m) => m.Transitions.Any(static t => t.AfterMs > 0); + private static void WriteArmInitialStateTimers(StringBuilder sb, StateMachineModel m) + { + if (!HasAnyTimedEdge(m)) return; + + var st = m.StateTypeFqn; + + sb.AppendLine(); + sb.AppendLine($" /// Arms timers for any timed edges whose From state matches the current state."); + sb.AppendLine($" private void ArmInitialStateTimers()"); + sb.AppendLine($" {{"); + sb.AppendLine($" var current = Current;"); + + foreach (var t in m.Transitions) + { + if (t.AfterMs == 0) continue; + if (t.Part is not null) continue; // group parts handled separately + + var field = $"_timer_{t.From}_{t.On}"; + sb.AppendLine($" if (current == {st}.{t.From})"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __t = {field};"); + sb.AppendLine($" if (__t is null)"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __new = new System.Threading.Timer("); + sb.AppendLine($" static s => (({m.ClassName})s!).TryFire({m.TriggerTypeFqn}.{t.On}),"); + sb.AppendLine($" this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" __t = System.Threading.Interlocked.CompareExchange(ref {field}, __new, null) ?? __new;"); + sb.AppendLine($" if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose();"); + sb.AppendLine($" }}"); + sb.AppendLine($" __t.Change({t.AfterMs}, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" }}"); + } + + sb.AppendLine($" }}"); + } + private static void WriteDispose(StringBuilder sb, StateMachineModel m) { if (!HasAnyTimedEdge(m)) return; diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs index 90de062..556110e 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs @@ -131,4 +131,36 @@ public void Dispose() _timer_B_ToC?.Dispose(); System.GC.SuppressFinalize(this); } + + /// Arms timers for any timed edges whose From state matches the current state. + private void ArmInitialStateTimers() + { + var current = Current; + if (current == global::MyApp.MS.A) + { + var __t = _timer_A_ToB; + if (__t is null) + { + var __new = new System.Threading.Timer( + static s => ((Multi)s!).TryFire(global::MyApp.MT.ToB), + this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + __t = System.Threading.Interlocked.CompareExchange(ref _timer_A_ToB, __new, null) ?? __new; + if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose(); + } + __t.Change(1000, System.Threading.Timeout.Infinite); + } + if (current == global::MyApp.MS.B) + { + var __t = _timer_B_ToC; + if (__t is null) + { + var __new = new System.Threading.Timer( + static s => ((Multi)s!).TryFire(global::MyApp.MT.ToC), + this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + __t = System.Threading.Interlocked.CompareExchange(ref _timer_B_ToC, __new, null) ?? __new; + if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose(); + } + __t.Change(2000, System.Threading.Timeout.Infinite); + } + } } diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs index 1d558fa..9133a52 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs @@ -108,4 +108,23 @@ public void Dispose() _timer_Working_Timeout?.Dispose(); System.GC.SuppressFinalize(this); } + + /// Arms timers for any timed edges whose From state matches the current state. + private void ArmInitialStateTimers() + { + var current = Current; + if (current == global::MyApp.WdState.Working) + { + var __t = _timer_Working_Timeout; + if (__t is null) + { + var __new = new System.Threading.Timer( + static s => ((Watchdog)s!).TryFire(global::MyApp.WdTrigger.Timeout), + this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + __t = System.Threading.Interlocked.CompareExchange(ref _timer_Working_Timeout, __new, null) ?? __new; + if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose(); + } + __t.Change(5000, System.Threading.Timeout.Infinite); + } + } } From 1c750f7551f24d940aba46b38a6fc59a50b4c1e3 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:47:36 +0200 Subject: [PATCH 15/23] feat(generator): emit HookConstructor + default ctor in StateMachineWriter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../StateMachineGenerator.cs | 6 +++++ .../StateMachineModel.cs | 1 + .../StateMachineWriter.cs | 24 +++++++++++++++++++ ...ltipleTimedEdges#MyApp_Multi.g.verified.cs | 12 ++++++++++ ...ngleTimedEdge#MyApp_Watchdog.g.verified.cs | 12 ++++++++++ 5 files changed, 55 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 6384755..c14361e 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -338,6 +338,8 @@ private static void AnalyzeCompositeInGroup( ? null : type.ContainingNamespace.ToDisplayString(); var isStruct = type.TypeKind == TypeKind.Struct; + var hasUserCtor = type.InstanceConstructors + .Any(c => !c.IsImplicitlyDeclared); return new StateMachineModel( ns, type.Name, isStruct, @@ -346,6 +348,7 @@ private static void AnalyzeCompositeInGroup( triggerTypeFqn, triggerTypeShort!, transitions, terminalStates, compositeStates, historyStates, + HasUserCtor: hasUserCtor, Diagram: diagram, Diagnostics: ImmutableArray.Empty); } @@ -379,6 +382,8 @@ private static void AnalyzeCompositeInGroup( ? null : type.ContainingNamespace.ToDisplayString(); var isStruct = type.TypeKind == TypeKind.Struct; + var hasUserCtor = type.InstanceConstructors + .Any(c => !c.IsImplicitlyDeclared); var diagnostics = ImmutableArray.CreateBuilder(); ct.ThrowIfCancellationRequested(); @@ -398,6 +403,7 @@ private static void AnalyzeCompositeInGroup( triggerTypeShort ?? string.Empty, transitions, terminalStates, compositeStates, historyStates, + HasUserCtor: hasUserCtor, Diagram: diagram, diagnostics.ToImmutable()); } diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs index 804d0d6..9e1b1c3 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineModel.cs @@ -18,6 +18,7 @@ internal sealed record StateMachineModel( ImmutableArray TerminalStates, // short enum member names ImmutableArray CompositeStates, ImmutableArray HistoryStates, + bool HasUserCtor, bool Diagram, ImmutableArray Diagnostics ); diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs index 146f900..fe6149b 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs @@ -355,6 +355,8 @@ private static void WriteConcurrentBody(StringBuilder sb, StateMachineModel m) WriteDispose(sb, m); WriteArmInitialStateTimers(sb, m); + + WriteHookConstructorAndCtor(sb, m); } private static void WriteConcurrentTryFire(StringBuilder sb, StateMachineModel m) @@ -585,4 +587,26 @@ private static void WriteDispose(StringBuilder sb, StateMachineModel m) sb.AppendLine($" System.GC.SuppressFinalize(this);"); sb.AppendLine($" }}"); } + + private static void WriteHookConstructorAndCtor(StringBuilder sb, StateMachineModel m) + { + if (!HasAnyTimedEdge(m)) return; + + sb.AppendLine(); + sb.AppendLine($" /// Generator-emitted partial hook invoked from the constructor. Arms initial-state timers."); + sb.AppendLine($" private void HookConstructor()"); + sb.AppendLine($" {{"); + sb.AppendLine($" ArmInitialStateTimers();"); + sb.AppendLine($" }}"); + + if (!m.HasUserCtor) + { + sb.AppendLine(); + sb.AppendLine($" /// Default generator-emitted constructor; calls HookConstructor() to arm initial-state timers."); + sb.AppendLine($" public {m.ClassName}()"); + sb.AppendLine($" {{"); + sb.AppendLine($" HookConstructor();"); + sb.AppendLine($" }}"); + } + } } diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs index 556110e..f82eaac 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs @@ -163,4 +163,16 @@ private void ArmInitialStateTimers() __t.Change(2000, System.Threading.Timeout.Infinite); } } + + /// Generator-emitted partial hook invoked from the constructor. Arms initial-state timers. + private void HookConstructor() + { + ArmInitialStateTimers(); + } + + /// Default generator-emitted constructor; calls HookConstructor() to arm initial-state timers. + public Multi() + { + HookConstructor(); + } } diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs index 9133a52..ca3cdea 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs @@ -127,4 +127,16 @@ private void ArmInitialStateTimers() __t.Change(5000, System.Threading.Timeout.Infinite); } } + + /// Generator-emitted partial hook invoked from the constructor. Arms initial-state timers. + private void HookConstructor() + { + ArmInitialStateTimers(); + } + + /// Default generator-emitted constructor; calls HookConstructor() to arm initial-state timers. + public Watchdog() + { + HookConstructor(); + } } From c14e451fe752c4e79a768216f295534cc346959f Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:51:49 +0200 Subject: [PATCH 16/23] feat(generator): Reset() and ResetTo(state) now arm initial-state timers 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) --- .../StateMachineWriter.cs | 10 ++++++++++ ...rTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs | 2 ++ ...rTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs | 2 ++ 3 files changed, 14 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs index fe6149b..bcf4ad3 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineWriter.cs @@ -422,6 +422,11 @@ private static void WriteResetMechanics(StringBuilder sb, StateMachineModel m) sb.AppendLine($" _subFsm_{c.State}.Reset();"); } + if (HasAnyTimedEdge(m)) + { + sb.AppendLine($" ArmInitialStateTimers();"); + } + sb.AppendLine($" }}"); sb.AppendLine(); @@ -443,6 +448,11 @@ private static void WriteResetMechanics(StringBuilder sb, StateMachineModel m) sb.AppendLine($" }}"); } + if (HasAnyTimedEdge(m)) + { + sb.AppendLine($" ArmInitialStateTimers();"); + } + sb.AppendLine($" }}"); } diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs index f82eaac..ccd6083 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.MultipleTimedEdges#MyApp_Multi.g.verified.cs @@ -79,6 +79,7 @@ public bool TryFire(global::MyApp.MT trigger) internal void Reset() { _state = (long)global::MyApp.MS.A; + ArmInitialStateTimers(); } /// Sets the machine to . Does NOT fire OnExit/OnEnter -- state-population only. @@ -86,6 +87,7 @@ internal void Reset() internal void ResetTo(global::MyApp.MS state) { _state = (long)state; + ArmInitialStateTimers(); } private void OnExit(global::MyApp.MS state, global::MyApp.MT trigger) diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs index ca3cdea..3ad0315 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/TimedTransitionGeneratorTests.SingleTimedEdge#MyApp_Watchdog.g.verified.cs @@ -63,6 +63,7 @@ public bool TryFire(global::MyApp.WdTrigger trigger) internal void Reset() { _state = (long)global::MyApp.WdState.Idle; + ArmInitialStateTimers(); } /// Sets the machine to . Does NOT fire OnExit/OnEnter -- state-population only. @@ -70,6 +71,7 @@ internal void Reset() internal void ResetTo(global::MyApp.WdState state) { _state = (long)state; + ArmInitialStateTimers(); } private void OnExit(global::MyApp.WdState state, global::MyApp.WdTrigger trigger) From e41443dce9699a552433f884010d136331a9ed4d Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 09:57:37 +0200 Subject: [PATCH 17/23] feat(generator): emit per-part ArmInitialStateTimers + group ctor in 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) --- .../StateMachineGenerator.cs | 2 + .../StateMachineGroupModel.cs | 1 + .../StateMachineGroupWriter.cs | 61 +++++++++++++++++++ ...Edge#MyApp_DeviceTimed.Group.g.verified.cs | 29 +++++++++ 4 files changed, 93 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index c14361e..91149ac 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -102,12 +102,14 @@ private static void EmitStateMachine(SourceProductionContext ctx, StateMachineMo .FirstOrDefault(kv => string.Equals(kv.Key, "Diagram", StringComparison.Ordinal)).Value.Value is true; var parts = CollectGroupParts(type); + var hasUserCtor = type.InstanceConstructors.Any(c => !c.IsImplicitlyDeclared); ct.ThrowIfCancellationRequested(); AnalyzeGroupDiagnostics(type, parts, diagram, diagnostics); return new StateMachineGroupModel( ns, type.Name, parts, + HasUserCtor: hasUserCtor, Diagram: diagram, diagnostics.ToImmutable()); } diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs index 113c33d..8fd04c1 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupModel.cs @@ -8,6 +8,7 @@ internal sealed record StateMachineGroupModel( string? Namespace, string ClassName, ImmutableArray Parts, + bool HasUserCtor, bool Diagram, ImmutableArray Diagnostics ); diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs index 83ae8e1..286aa24 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGroupWriter.cs @@ -30,6 +30,7 @@ public static string Write(StateMachineGroupModel m) } if (hasAnyTimer) WriteGroupDispose(sb, m); + WriteGroupHookAndCtor(sb, m); if (m.Diagram) { @@ -51,6 +52,7 @@ private static void WritePartBody(StringBuilder sb, string className, StateMachi WritePartCurrentProperty(sb, p); WritePartTryFire(sb, className, p); WritePartHooks(sb, p); + WritePartArmInitialStateTimers(sb, className, p); } private static void WritePartFields(StringBuilder sb, StateMachinePartModel p) @@ -187,6 +189,65 @@ private static void WritePartHooks(StringBuilder sb, StateMachinePartModel p) } } + private static void WritePartArmInitialStateTimers(StringBuilder sb, string className, StateMachinePartModel p) + { + var hasTimed = p.Transitions.Any(static t => t.AfterMs > 0); + if (!hasTimed) return; + + var st = p.StateTypeFqn; + var tr = p.TriggerTypeFqn; + + sb.AppendLine(); + sb.AppendLine($" private void ArmInitialStateTimers_{p.Name}()"); + sb.AppendLine($" {{"); + sb.AppendLine($" var current = {p.Name}Current;"); + foreach (var t in p.Transitions) + { + if (t.AfterMs == 0) continue; + var field = $"_timer_{p.Name}_{t.From}_{t.On}"; + sb.AppendLine($" if (current == {st}.{t.From})"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __t = {field};"); + sb.AppendLine($" if (__t is null)"); + sb.AppendLine($" {{"); + sb.AppendLine($" var __new = new System.Threading.Timer("); + sb.AppendLine($" static s => (({className})s!).TryFire{p.Name}({tr}.{t.On}),"); + sb.AppendLine($" this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" __t = System.Threading.Interlocked.CompareExchange(ref {field}, __new, null) ?? __new;"); + sb.AppendLine($" if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose();"); + sb.AppendLine($" }}"); + sb.AppendLine($" __t.Change({t.AfterMs}, System.Threading.Timeout.Infinite);"); + sb.AppendLine($" }}"); + } + sb.AppendLine($" }}"); + } + + private static void WriteGroupHookAndCtor(StringBuilder sb, StateMachineGroupModel m) + { + var anyTimed = m.Parts.Any(static p => p.Transitions.Any(static t => t.AfterMs > 0)); + if (!anyTimed) return; + + sb.AppendLine(); + sb.AppendLine($" /// Generator-emitted partial hook invoked from the constructor."); + sb.AppendLine($" private void HookConstructor()"); + sb.AppendLine($" {{"); + foreach (var p in m.Parts) + { + if (p.Transitions.Any(static t => t.AfterMs > 0)) + sb.AppendLine($" ArmInitialStateTimers_{p.Name}();"); + } + sb.AppendLine($" }}"); + + if (!m.HasUserCtor) + { + sb.AppendLine(); + sb.AppendLine($" public {m.ClassName}()"); + sb.AppendLine($" {{"); + sb.AppendLine($" HookConstructor();"); + sb.AppendLine($" }}"); + } + } + private static void WriteGroupDispose(StringBuilder sb, StateMachineGroupModel m) { sb.AppendLine(); diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/StateMachineGroupGeneratorTests.TwoPartsOneTimedEdge#MyApp_DeviceTimed.Group.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/StateMachineGroupGeneratorTests.TwoPartsOneTimedEdge#MyApp_DeviceTimed.Group.g.verified.cs index eb1dd6e..bed03a5 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/StateMachineGroupGeneratorTests.TwoPartsOneTimedEdge#MyApp_DeviceTimed.Group.g.verified.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/StateMachineGroupGeneratorTests.TwoPartsOneTimedEdge#MyApp_DeviceTimed.Group.g.verified.cs @@ -81,6 +81,24 @@ private void OnEnterOperational(global::MyApp.OpState state, global::MyApp.OpSta /// Called after entering Faulted on part "Operational". partial void OnEnterOperationalFaulted(global::MyApp.OpState from); + private void ArmInitialStateTimers_Operational() + { + var current = OperationalCurrent; + if (current == global::MyApp.OpState.Running) + { + var __t = _timer_Operational_Running_Fault; + if (__t is null) + { + var __new = new System.Threading.Timer( + static s => ((DeviceTimed)s!).TryFireOperational(global::MyApp.OpTrigger.Fault), + this, System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + __t = System.Threading.Interlocked.CompareExchange(ref _timer_Operational_Running_Fault, __new, null) ?? __new; + if (!System.Object.ReferenceEquals(__t, __new)) __new.Dispose(); + } + __t.Change(10000, System.Threading.Timeout.Infinite); + } + } + // ── Part: Connection ──────────────────────────────────────── private long _state_Connection = (long)global::MyApp.ConnState.Disconnected; @@ -147,4 +165,15 @@ public void Dispose() _timer_Operational_Running_Fault?.Dispose(); System.GC.SuppressFinalize(this); } + + /// Generator-emitted partial hook invoked from the constructor. + private void HookConstructor() + { + ArmInitialStateTimers_Operational(); + } + + public DeviceTimed() + { + HookConstructor(); + } } From 7ff104302200d6ddafb1caff491c60f001a61471 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 10:01:44 +0200 Subject: [PATCH 18/23] feat(generator): detect ZSM0021 (user-declared ctor must call HookConstructor) 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) --- .../StateMachineGenerator.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 91149ac..539779b 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -219,6 +219,9 @@ private static void AnalyzeGroupDiagnostics( diagnostics.Add(Diagnostic.Create( StateMachineDiagnostics.EmptyDiagramRequest, location, type.Name)); } + + var hasTimedInGroup = parts.Any(static p => p.Transitions.Any(static t => t.AfterMs > 0)); + AnalyzeMissingHookConstructorInvocation(type, hasTimedInGroup, diagnostics); } // ZSM0014: [StateMachine] and [StateMachineGroup] on the same class @@ -602,6 +605,9 @@ private static void AnalyzeDiagnostics( AnalyzeTimedTransitions(transitions, stateTypeShort, type, concurrent, diagnostics); AnalyzeDisposeConflict(type, transitions, diagnostics); AnalyzeEmptyDiagramRequest(diagram, transitions, type, diagnostics); + + var hasTimed = transitions.Any(static t => t.AfterMs > 0); + AnalyzeMissingHookConstructorInvocation(type, hasTimed, diagnostics); } private static void AnalyzeEmptyDiagramRequest( @@ -618,6 +624,49 @@ private static void AnalyzeEmptyDiagramRequest( StateMachineDiagnostics.EmptyDiagramRequest, location, type.Name)); } + private static void AnalyzeMissingHookConstructorInvocation( + INamedTypeSymbol type, + bool hasTimedEdges, + ImmutableArray.Builder diagnostics) + { + if (!hasTimedEdges) return; + + var userCtors = type.InstanceConstructors + .Where(c => !c.IsImplicitlyDeclared) + .ToArray(); + if (userCtors.Length == 0) return; + + var location = type.Locations.Length > 0 ? type.Locations[0] : Location.None; + + foreach (var ctor in userCtors) + { + if (CtorInvokesHookConstructor(ctor)) return; + } + + diagnostics.Add(Diagnostic.Create( + StateMachineDiagnostics.MissingHookConstructorInvocation, location, type.Name)); + } + + private static bool CtorInvokesHookConstructor(IMethodSymbol ctor) + { + foreach (var syntaxRef in ctor.DeclaringSyntaxReferences) + { + var node = syntaxRef.GetSyntax(); + if (node is null) continue; + + // Walk the ctor body's descendant invocations; look for HookConstructor(). + foreach (var inv in node.DescendantNodes().OfType()) + { + if (inv.Expression is Microsoft.CodeAnalysis.CSharp.Syntax.IdentifierNameSyntax id && + string.Equals(id.Identifier.ValueText, "HookConstructor", StringComparison.Ordinal)) + { + return true; + } + } + } + return false; + } + private static void AnalyzeReachability( string initialState, ImmutableArray terminalStates, From 375f485e9b27b5ae4ba6ce31cf1e8e97b34c0a75 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 10:07:06 +0200 Subject: [PATCH 19/23] test(generator): snapshot tests for Mermaid diagram emit (B4) 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) --- .../MermaidDiagramGeneratorTests.cs | 79 ++++++++++ ....Composite_Diagram#MyApp_App.g.verified.cs | 119 +++++++++++++++ ...ite_Diagram#MyApp_LoadingFsm.g.verified.cs | 77 ++++++++++ ...sts.Flat_Diagram#MyApp_Order.g.verified.cs | 88 +++++++++++ ...p_Diagram#MyApp_Device.Group.g.verified.cs | 140 ++++++++++++++++++ 5 files changed, 503 insertions(+) create mode 100644 tests/ZeroAlloc.StateMachine.Generator.Tests/MermaidDiagramGeneratorTests.cs create mode 100644 tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_App.g.verified.cs create mode 100644 tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_LoadingFsm.g.verified.cs create mode 100644 tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Flat_Diagram#MyApp_Order.g.verified.cs create mode 100644 tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Group_Diagram#MyApp_Device.Group.g.verified.cs diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/MermaidDiagramGeneratorTests.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/MermaidDiagramGeneratorTests.cs new file mode 100644 index 0000000..685ee0a --- /dev/null +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/MermaidDiagramGeneratorTests.cs @@ -0,0 +1,79 @@ +namespace ZeroAlloc.StateMachine.Generator.Tests; + +using System.Threading.Tasks; +using Xunit; + +public class MermaidDiagramGeneratorTests +{ + [Fact] + public Task Flat_Diagram() + { + const string source = @" +using ZeroAlloc.StateMachine; +namespace MyApp; + +public enum OS { Idle, Submitted, Shipped } +public enum OT { Submit, Ship, Cancel } + +[StateMachine(InitialState = ""Idle"", Diagram = true)] +[Transition(From = OS.Idle, On = OT.Submit, To = OS.Submitted)] +[Transition(From = OS.Submitted, On = OT.Ship, To = OS.Shipped, When = true)] +[Terminal(State = OS.Shipped)] +public partial class Order { } +"; + return TestHelper.Verify(source); + } + + [Fact] + public Task Composite_Diagram() + { + const string source = @" +using ZeroAlloc.StateMachine; +namespace MyApp; + +public enum LoadingState { Fetching, Parsing, Done } +public enum AppTrigger { Begin, Tick, Complete } + +[StateMachine(InitialState = ""Fetching"")] +[Transition(From = LoadingState.Fetching, On = AppTrigger.Tick, To = LoadingState.Parsing)] +[Transition(From = LoadingState.Parsing, On = AppTrigger.Complete, To = LoadingState.Done)] +[Terminal(State = LoadingState.Done)] +public partial class LoadingFsm { } + +public enum AppState { Idle, Loading, Ready } + +[StateMachine(InitialState = ""Idle"", Diagram = true)] +[Transition(From = AppState.Idle, On = AppTrigger.Begin, To = AppState.Loading)] +[Transition(From = AppState.Loading, On = AppTrigger.Complete, To = AppState.Ready)] +[CompositeState(State = AppState.Loading, SubMachine = typeof(LoadingFsm))] +[HistoryState(State = AppState.Loading)] +[Terminal(State = AppState.Ready)] +public partial class App { } +"; + return TestHelper.Verify(source); + } + + [Fact] + public Task Group_Diagram() + { + const string source = @" +using ZeroAlloc.StateMachine; +namespace MyApp; + +public enum OpS { Idle, Running } +public enum OpT { Start, Stop } +public enum ConnS { Disconnected, Connected } +public enum ConnT { Connect, Disconnect } + +[StateMachineGroup(Diagram = true)] +[StateMachinePart(Name = ""Op"", InitialState = OpS.Idle)] +[StateMachinePart(Name = ""Conn"", InitialState = ConnS.Disconnected)] +[Transition(From = OpS.Idle, On = OpT.Start, To = OpS.Running, Part = ""Op"")] +[Transition(From = OpS.Running, On = OpT.Stop, To = OpS.Idle, Part = ""Op"")] +[Transition(From = ConnS.Disconnected, On = ConnT.Connect, To = ConnS.Connected, Part = ""Conn"")] +[Transition(From = ConnS.Connected, On = ConnT.Disconnect, To = ConnS.Disconnected, Part = ""Conn"")] +public partial class Device { } +"; + return TestHelper.Verify(source); + } +} diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_App.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_App.g.verified.cs new file mode 100644 index 0000000..880b758 --- /dev/null +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_App.g.verified.cs @@ -0,0 +1,119 @@ +//HintName: MyApp_App.g.cs +// +#nullable enable + +namespace MyApp; + +partial class App +{ + private global::MyApp.AppState _state = global::MyApp.AppState.Idle; + + private readonly global::MyApp.LoadingFsm _subFsm_Loading = new(); + private global::MyApp.LoadingState _history_Loading; + private bool _hasHistory_Loading; + + /// Current state of the machine. + public global::MyApp.AppState Current => _state; + + private bool TryFireSubMachine(global::MyApp.AppTrigger trigger) => _state switch + { + global::MyApp.AppState.Loading => _subFsm_Loading.TryFire(trigger), + _ => false + }; + + /// + /// Attempt to fire from the current state. + /// Returns true if the transition occurred; false if no matching transition or a guard rejected it. + /// + public bool TryFire(global::MyApp.AppTrigger trigger) + { + if (TryFireSubMachine(trigger)) return true; + + return (Current, trigger) switch + { + (global::MyApp.AppState.Idle, global::MyApp.AppTrigger.Begin) => Fire(global::MyApp.AppState.Idle, global::MyApp.AppState.Loading, trigger), + (global::MyApp.AppState.Loading, global::MyApp.AppTrigger.Complete) => Fire(global::MyApp.AppState.Loading, global::MyApp.AppState.Ready, trigger), + _ => false + }; + } + + private bool Fire(global::MyApp.AppState from, global::MyApp.AppState to, global::MyApp.AppTrigger trigger) + { + OnExit(from, trigger); + if (from == global::MyApp.AppState.Loading) + { + _history_Loading = _subFsm_Loading.Current; + _hasHistory_Loading = true; + } + _state = to; + if (to == global::MyApp.AppState.Loading) + { + if (_hasHistory_Loading) _subFsm_Loading.ResetTo(_history_Loading); + else _subFsm_Loading.Reset(); + } + OnEnter(to, from); + return true; + } + + /// Resets the machine to its declared initial state. Does NOT fire OnExit/OnEnter -- state-population only. + internal void Reset() + { + _state = global::MyApp.AppState.Idle; + _subFsm_Loading.Reset(); + } + + /// Sets the machine to . Does NOT fire OnExit/OnEnter -- state-population only. + /// If is itself a composite, the sub-FSM is reset to its initial state (shallow history contract). + internal void ResetTo(global::MyApp.AppState state) + { + _state = state; + switch (state) + { + case global::MyApp.AppState.Loading: _subFsm_Loading.Reset(); break; + default: break; + } + } + + private void OnExit(global::MyApp.AppState state, global::MyApp.AppTrigger trigger) + { + switch (state) + { + case global::MyApp.AppState.Idle: OnExitIdle(trigger); break; + case global::MyApp.AppState.Loading: OnExitLoading(trigger); break; + } + } + + private void OnEnter(global::MyApp.AppState state, global::MyApp.AppState from) + { + switch (state) + { + case global::MyApp.AppState.Loading: OnEnterLoading(from); break; + case global::MyApp.AppState.Ready: OnEnterReady(from); break; + } + } + + + // ── Partial hooks — implement what you need, leave the rest ───────────── + /// Called before leaving Idle. + partial void OnExitIdle(global::MyApp.AppTrigger on); + /// Called before leaving Loading. + partial void OnExitLoading(global::MyApp.AppTrigger on); + /// Called after entering Loading. + partial void OnEnterLoading(global::MyApp.AppState from); + /// Called after entering Ready. + partial void OnEnterReady(global::MyApp.AppState from); + + /// Mermaid stateDiagram-v2 representation of this state machine. + public const string MermaidDiagram = @"stateDiagram-v2 + [*] --> Idle + Idle --> Loading: Begin + Loading --> Ready: Complete + Ready --> [*] + state Loading { + state H as History + [*] --> Fetching + Fetching --> Parsing: Tick + Parsing --> Done: Complete + Done --> [*] + }"; +} diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_LoadingFsm.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_LoadingFsm.g.verified.cs new file mode 100644 index 0000000..c47e762 --- /dev/null +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Composite_Diagram#MyApp_LoadingFsm.g.verified.cs @@ -0,0 +1,77 @@ +//HintName: MyApp_LoadingFsm.g.cs +// +#nullable enable + +namespace MyApp; + +partial class LoadingFsm +{ + private global::MyApp.LoadingState _state = global::MyApp.LoadingState.Fetching; + + /// Current state of the machine. + public global::MyApp.LoadingState Current => _state; + + /// + /// Attempt to fire from the current state. + /// Returns true if the transition occurred; false if no matching transition or a guard rejected it. + /// + public bool TryFire(global::MyApp.AppTrigger trigger) + { + return (Current, trigger) switch + { + (global::MyApp.LoadingState.Fetching, global::MyApp.AppTrigger.Tick) => Fire(global::MyApp.LoadingState.Fetching, global::MyApp.LoadingState.Parsing, trigger), + (global::MyApp.LoadingState.Parsing, global::MyApp.AppTrigger.Complete) => Fire(global::MyApp.LoadingState.Parsing, global::MyApp.LoadingState.Done, trigger), + _ => false + }; + } + + private bool Fire(global::MyApp.LoadingState from, global::MyApp.LoadingState to, global::MyApp.AppTrigger trigger) + { + OnExit(from, trigger); + _state = to; + OnEnter(to, from); + return true; + } + + /// Resets the machine to its declared initial state. Does NOT fire OnExit/OnEnter -- state-population only. + internal void Reset() + { + _state = global::MyApp.LoadingState.Fetching; + } + + /// Sets the machine to . Does NOT fire OnExit/OnEnter -- state-population only. + /// If is itself a composite, the sub-FSM is reset to its initial state (shallow history contract). + internal void ResetTo(global::MyApp.LoadingState state) + { + _state = state; + } + + private void OnExit(global::MyApp.LoadingState state, global::MyApp.AppTrigger trigger) + { + switch (state) + { + case global::MyApp.LoadingState.Fetching: OnExitFetching(trigger); break; + case global::MyApp.LoadingState.Parsing: OnExitParsing(trigger); break; + } + } + + private void OnEnter(global::MyApp.LoadingState state, global::MyApp.LoadingState from) + { + switch (state) + { + case global::MyApp.LoadingState.Parsing: OnEnterParsing(from); break; + case global::MyApp.LoadingState.Done: OnEnterDone(from); break; + } + } + + + // ── Partial hooks — implement what you need, leave the rest ───────────── + /// Called before leaving Fetching. + partial void OnExitFetching(global::MyApp.AppTrigger on); + /// Called before leaving Parsing. + partial void OnExitParsing(global::MyApp.AppTrigger on); + /// Called after entering Parsing. + partial void OnEnterParsing(global::MyApp.LoadingState from); + /// Called after entering Done. + partial void OnEnterDone(global::MyApp.LoadingState from); +} diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Flat_Diagram#MyApp_Order.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Flat_Diagram#MyApp_Order.g.verified.cs new file mode 100644 index 0000000..1707005 --- /dev/null +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Flat_Diagram#MyApp_Order.g.verified.cs @@ -0,0 +1,88 @@ +//HintName: MyApp_Order.g.cs +// +#nullable enable + +namespace MyApp; + +partial class Order +{ + private global::MyApp.OS _state = global::MyApp.OS.Idle; + + /// Current state of the machine. + public global::MyApp.OS Current => _state; + + /// + /// Attempt to fire from the current state. + /// Returns true if the transition occurred; false if no matching transition or a guard rejected it. + /// + public bool TryFire(global::MyApp.OT trigger) + { + return (Current, trigger) switch + { + (global::MyApp.OS.Idle, global::MyApp.OT.Submit) => Fire(global::MyApp.OS.Idle, global::MyApp.OS.Submitted, trigger), + (global::MyApp.OS.Submitted, global::MyApp.OT.Ship) + when GuardShip(global::MyApp.OS.Submitted, global::MyApp.OT.Ship) + => Fire(global::MyApp.OS.Submitted, global::MyApp.OS.Shipped, trigger), + _ => false + }; + } + + private bool Fire(global::MyApp.OS from, global::MyApp.OS to, global::MyApp.OT trigger) + { + OnExit(from, trigger); + _state = to; + OnEnter(to, from); + return true; + } + + /// Resets the machine to its declared initial state. Does NOT fire OnExit/OnEnter -- state-population only. + internal void Reset() + { + _state = global::MyApp.OS.Idle; + } + + /// Sets the machine to . Does NOT fire OnExit/OnEnter -- state-population only. + /// If is itself a composite, the sub-FSM is reset to its initial state (shallow history contract). + internal void ResetTo(global::MyApp.OS state) + { + _state = state; + } + + private void OnExit(global::MyApp.OS state, global::MyApp.OT trigger) + { + switch (state) + { + case global::MyApp.OS.Idle: OnExitIdle(trigger); break; + case global::MyApp.OS.Submitted: OnExitSubmitted(trigger); break; + } + } + + private void OnEnter(global::MyApp.OS state, global::MyApp.OS from) + { + switch (state) + { + case global::MyApp.OS.Submitted: OnEnterSubmitted(from); break; + case global::MyApp.OS.Shipped: OnEnterShipped(from); break; + } + } + + + // ── Partial hooks — implement what you need, leave the rest ───────────── + /// Guard for the Submitted → Shipped transition on Ship. Implement this method; return false to block the transition. + private partial bool GuardShip(global::MyApp.OS from, global::MyApp.OT on); + /// Called before leaving Idle. + partial void OnExitIdle(global::MyApp.OT on); + /// Called before leaving Submitted. + partial void OnExitSubmitted(global::MyApp.OT on); + /// Called after entering Submitted. + partial void OnEnterSubmitted(global::MyApp.OS from); + /// Called after entering Shipped. + partial void OnEnterShipped(global::MyApp.OS from); + + /// Mermaid stateDiagram-v2 representation of this state machine. + public const string MermaidDiagram = @"stateDiagram-v2 + [*] --> Idle + Idle --> Submitted: Submit + Submitted --> Shipped: Ship [guard] + Shipped --> [*]"; +} diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Group_Diagram#MyApp_Device.Group.g.verified.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Group_Diagram#MyApp_Device.Group.g.verified.cs new file mode 100644 index 0000000..bcd90dd --- /dev/null +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/Snapshots/MermaidDiagramGeneratorTests.Group_Diagram#MyApp_Device.Group.g.verified.cs @@ -0,0 +1,140 @@ +//HintName: MyApp_Device.Group.g.cs +// +#nullable enable + +namespace MyApp; + +partial class Device +{ + // ── Part: Op ──────────────────────────────────────── + private long _state_Op = (long)global::MyApp.OpS.Idle; + + /// Current state of part "Op" (thread-safe read). + public global::MyApp.OpS OpCurrent => (global::MyApp.OpS)System.Threading.Volatile.Read(ref _state_Op); + + /// Attempt to fire on part "Op". Returns true if the transition occurred. + public bool TryFireOp(global::MyApp.OpT trigger) + { + while (true) + { + var current = (global::MyApp.OpS)System.Threading.Volatile.Read(ref _state_Op); + global::MyApp.OpS? next = (current, trigger) switch + { + (global::MyApp.OpS.Idle, global::MyApp.OpT.Start) => (global::MyApp.OpS?)global::MyApp.OpS.Running, + (global::MyApp.OpS.Running, global::MyApp.OpT.Stop) => (global::MyApp.OpS?)global::MyApp.OpS.Idle, + _ => null + }; + + if (next is null) return false; + + if (System.Threading.Interlocked.CompareExchange( + ref _state_Op, (long)next.Value, (long)current) == (long)current) + { + OnExitOp(current, trigger); + OnEnterOp(next.Value, current); + return true; + } + } + } + + private void OnExitOp(global::MyApp.OpS state, global::MyApp.OpT trigger) + { + switch (state) + { + case global::MyApp.OpS.Idle: OnExitOpIdle(trigger); break; + case global::MyApp.OpS.Running: OnExitOpRunning(trigger); break; + } + } + + private void OnEnterOp(global::MyApp.OpS state, global::MyApp.OpS from) + { + switch (state) + { + case global::MyApp.OpS.Running: OnEnterOpRunning(from); break; + case global::MyApp.OpS.Idle: OnEnterOpIdle(from); break; + } + } + + // ── Partial hooks for part "Op" — implement what you need + /// Called after leaving Idle on part "Op". + partial void OnExitOpIdle(global::MyApp.OpT on); + /// Called after leaving Running on part "Op". + partial void OnExitOpRunning(global::MyApp.OpT on); + /// Called after entering Running on part "Op". + partial void OnEnterOpRunning(global::MyApp.OpS from); + /// Called after entering Idle on part "Op". + partial void OnEnterOpIdle(global::MyApp.OpS from); + + // ── Part: Conn ──────────────────────────────────────── + private long _state_Conn = (long)global::MyApp.ConnS.Disconnected; + + /// Current state of part "Conn" (thread-safe read). + public global::MyApp.ConnS ConnCurrent => (global::MyApp.ConnS)System.Threading.Volatile.Read(ref _state_Conn); + + /// Attempt to fire on part "Conn". Returns true if the transition occurred. + public bool TryFireConn(global::MyApp.ConnT trigger) + { + while (true) + { + var current = (global::MyApp.ConnS)System.Threading.Volatile.Read(ref _state_Conn); + global::MyApp.ConnS? next = (current, trigger) switch + { + (global::MyApp.ConnS.Disconnected, global::MyApp.ConnT.Connect) => (global::MyApp.ConnS?)global::MyApp.ConnS.Connected, + (global::MyApp.ConnS.Connected, global::MyApp.ConnT.Disconnect) => (global::MyApp.ConnS?)global::MyApp.ConnS.Disconnected, + _ => null + }; + + if (next is null) return false; + + if (System.Threading.Interlocked.CompareExchange( + ref _state_Conn, (long)next.Value, (long)current) == (long)current) + { + OnExitConn(current, trigger); + OnEnterConn(next.Value, current); + return true; + } + } + } + + private void OnExitConn(global::MyApp.ConnS state, global::MyApp.ConnT trigger) + { + switch (state) + { + case global::MyApp.ConnS.Disconnected: OnExitConnDisconnected(trigger); break; + case global::MyApp.ConnS.Connected: OnExitConnConnected(trigger); break; + } + } + + private void OnEnterConn(global::MyApp.ConnS state, global::MyApp.ConnS from) + { + switch (state) + { + case global::MyApp.ConnS.Connected: OnEnterConnConnected(from); break; + case global::MyApp.ConnS.Disconnected: OnEnterConnDisconnected(from); break; + } + } + + // ── Partial hooks for part "Conn" — implement what you need + /// Called after leaving Disconnected on part "Conn". + partial void OnExitConnDisconnected(global::MyApp.ConnT on); + /// Called after leaving Connected on part "Conn". + partial void OnExitConnConnected(global::MyApp.ConnT on); + /// Called after entering Connected on part "Conn". + partial void OnEnterConnConnected(global::MyApp.ConnS from); + /// Called after entering Disconnected on part "Conn". + partial void OnEnterConnDisconnected(global::MyApp.ConnS from); + + + /// Mermaid stateDiagram-v2 representation of this state-machine group. + public const string MermaidDiagram = @"stateDiagram-v2 + state Op { + [*] --> Idle + Idle --> Running: Start + Running --> Idle: Stop + } + state Conn { + [*] --> Disconnected + Disconnected --> Connected: Connect + Connected --> Disconnected: Disconnect + }"; +} From d6256aef60c6759261a4f0f338029173bd5c39a5 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 10:10:38 +0200 Subject: [PATCH 20/23] test(generator): diagnostic tests for ZSM0020 + ZSM0021 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) --- .../DiagnosticTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs index bfc558f..adb56c3 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs @@ -423,4 +423,63 @@ private void Dispose() { } // wrong: private (gen wants public) var diags = await TestHelper.GetDiagnostics(source); Assert.Contains(diags, d => string.Equals(d.Id, "ZSM0019", StringComparison.Ordinal)); } + + [Fact] + public async Task ZSM0020_FiresWhen_Diagram_OnEmptyClass() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A } public enum T { Go } +[StateMachine(InitialState = ""A"", Diagram = true)] +public partial class M { } +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.Contains(diags, d => string.Equals(d.Id, "ZSM0020", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0020_FiresWhen_Diagram_OnEmptyGroup() + { + const string source = @" +using ZeroAlloc.StateMachine; +[StateMachineGroup(Diagram = true)] +public partial class M { } +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.Contains(diags, d => string.Equals(d.Id, "ZSM0020", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0021_FiresWhen_UserCtor_DoesNotCall_HookConstructor() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A, B } public enum T { Go } +[StateMachine(InitialState = ""A"", Concurrent = true)] +[Transition(From = S.A, On = T.Go, To = S.B, AfterMs = 1000)] +public partial class M +{ + public M(int x) { /* does NOT call HookConstructor */ } +} +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.Contains(diags, d => string.Equals(d.Id, "ZSM0021", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0021_DoesNotFire_When_UserCtor_Calls_HookConstructor() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A, B } public enum T { Go } +[StateMachine(InitialState = ""A"", Concurrent = true)] +[Transition(From = S.A, On = T.Go, To = S.B, AfterMs = 1000)] +public partial class M +{ + public M(int x) { HookConstructor(); } +} +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.DoesNotContain(diags, d => string.Equals(d.Id, "ZSM0021", StringComparison.Ordinal)); + } } From be84eb49a98010b30998e30d69749b537ec209ea Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 10:13:20 +0200 Subject: [PATCH 21/23] test: runtime tests for initial-state arm (closes v1.4 caveat) 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) --- .../InitialStateArmTests.cs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/ZeroAlloc.StateMachine.Tests/InitialStateArmTests.cs diff --git a/tests/ZeroAlloc.StateMachine.Tests/InitialStateArmTests.cs b/tests/ZeroAlloc.StateMachine.Tests/InitialStateArmTests.cs new file mode 100644 index 0000000..4266f59 --- /dev/null +++ b/tests/ZeroAlloc.StateMachine.Tests/InitialStateArmTests.cs @@ -0,0 +1,49 @@ +#pragma warning disable MA0048 // file holds multiple top-level types +#pragma warning disable ZSM0002 // sink states are intentional + +namespace ZeroAlloc.StateMachine.Tests; + +using System.Threading.Tasks; +using Xunit; +using ZeroAlloc.StateMachine; + +public enum WatchState { Working, Dead } +public enum WatchTrigger { Timeout } + +[StateMachine(InitialState = "Working", Concurrent = true)] +[Transition(From = WatchState.Working, On = WatchTrigger.Timeout, To = WatchState.Dead, AfterMs = 500)] +[Terminal(State = WatchState.Dead)] +public partial class InitialArmWatchdog { } + +public class InitialStateArmTests +{ + [Fact] + public async Task Constructor_arms_initial_state_timer() + { + using var w = new InitialArmWatchdog(); + Assert.Equal(WatchState.Working, w.Current); + + // Give the timer time to fire — no user TryFire call. + await Task.Delay(1000); + Assert.Equal(WatchState.Dead, w.Current); + } + + [Fact] + public async Task Reset_rearms_initial_state_timer() + { + using var w = new InitialArmWatchdog(); + await Task.Delay(1000); + Assert.Equal(WatchState.Dead, w.Current); + + // Reset puts state back to Working; should re-arm the timer. + // Reset is internal — access via reflection. + var resetMethod = typeof(InitialArmWatchdog).GetMethod("Reset", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(resetMethod); + resetMethod!.Invoke(w, null); + Assert.Equal(WatchState.Working, w.Current); + + await Task.Delay(1000); + Assert.Equal(WatchState.Dead, w.Current); + } +} From 674aa4ff7e0d87ffeeea6b83db42d0066c66afe2 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 10:18:26 +0200 Subject: [PATCH 22/23] docs: diagram export (B4) + initial-arm closes v1.4 caveat - 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) --- docs/attributes.md | 5 +- docs/core-concepts/diagram-export.md | 126 ++++++++++++++++++++++ docs/core-concepts/timeout-transitions.md | 31 ++---- docs/diagnostics/ZSM0020.md | 59 ++++++++++ docs/diagnostics/ZSM0021.md | 72 +++++++++++++ docs/index.md | 3 + 6 files changed, 272 insertions(+), 24 deletions(-) create mode 100644 docs/core-concepts/diagram-export.md create mode 100644 docs/diagnostics/ZSM0020.md create mode 100644 docs/diagnostics/ZSM0021.md diff --git a/docs/attributes.md b/docs/attributes.md index 3939d2a..e7b35f8 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -25,6 +25,7 @@ Marks a `partial` class or struct as a source-generated state machine. The gener |----------|------|----------|---------|-------------| | `InitialState` | `string` | yes | — | The name of the initial state. **Always use `nameof(...)`** to keep it refactor-safe. | | `Concurrent` | `bool` | no | `false` | When `true`, state is stored as `volatile long` and transitions use `Interlocked.CompareExchange`. Safe for concurrent callers. Guards are not generated in this mode. | +| `Diagram` | `bool` | no | `false` | When `true`, the generator emits a `public const string MermaidDiagram` on the partial containing a Mermaid `stateDiagram-v2` rendering of the machine. See [Diagram Export](core-concepts/diagram-export.md). Emits **ZSM0020** on a class with no transitions. | ### Examples @@ -224,7 +225,9 @@ Marks a `partial` class as a group of independent concurrent state machines. Eac ### Properties -None. +| Property | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `Diagram` | `bool` | no | `false` | When `true`, the generator emits a `public const string MermaidDiagram` on the group partial. Each `[StateMachinePart]` renders as a top-level `state Name { ... }` block. See [Diagram Export](core-concepts/diagram-export.md). Emits **ZSM0020** on a group whose parts declare no transitions. | ### Example diff --git a/docs/core-concepts/diagram-export.md b/docs/core-concepts/diagram-export.md new file mode 100644 index 0000000..eed9a05 --- /dev/null +++ b/docs/core-concepts/diagram-export.md @@ -0,0 +1,126 @@ +--- +id: diagram-export +title: Diagram Export +sidebar_position: 7 +--- + +# Diagram Export + +`[StateMachine(Diagram = true)]` (and `[StateMachineGroup(Diagram = true)]`) +asks the generator to emit a **Mermaid `stateDiagram-v2`** rendering of the +machine's transitions next to the dispatcher. The diagram lands on the +partial as a `public const string MermaidDiagram` — paste it into any +Mermaid-aware renderer (GitHub, Markdown previewers, docs sites) to view it. + +--- + +## Why + +The transition graph is already fully declared in `[Transition]` attributes; +keeping a hand-drawn diagram in sync with the attributes is busywork that +rots. `Diagram = true` lets the source of truth — the attributes — drive a +human-readable picture at zero runtime cost (it's a compile-time string +literal). + +--- + +## How + +Add `Diagram = true` to the machine attribute: + +```csharp +using ZeroAlloc.StateMachine; + +public enum OrderState { Idle, Submitted, Shipped } +public enum OrderTrigger { Submit, Ship } + +[StateMachine(InitialState = nameof(OrderState.Idle), Diagram = true)] +[Transition(From = OrderState.Idle, On = OrderTrigger.Submit, To = OrderState.Submitted)] +[Transition(From = OrderState.Submitted, On = OrderTrigger.Ship, To = OrderState.Shipped)] +[Terminal(State = OrderState.Shipped)] +public partial class Order { } +``` + +The generated partial gains a constant you can read at runtime: + +```csharp +Console.WriteLine(Order.MermaidDiagram); +``` + +The emitted string is: + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Submitted: Submit + Submitted --> Shipped: Ship + Shipped --> [*] +``` + +--- + +## What gets rendered + +| Feature | Rendering | +|---|---| +| **Initial state** | `[*] --> InitialState` | +| **Flat transitions** | `From --> To: Trigger` | +| **Guards** (`When = true`) | `From --> To: Trigger [guard]` | +| **Timed edges** (`AfterMs = N`) | `From --> To: Trigger (after Nms)` | +| **Terminal states** (`[Terminal]`) | `State --> [*]` | +| **Composite states** | `state Parent { ... }` nested block, with the sub-FSM's own diagram inside | +| **Shallow history** (`[HistoryState]`) | `state H as History` marker inside the composite block | +| **Concurrent parts** (`[StateMachineGroup]`) | one top-level `state PartName { ... }` block per `[StateMachinePart]` | + +The output is a string literal — no reflection, no runtime cost, AOT-safe. + +If `Diagram = true` is set on a class with zero transitions, the generator +emits [ZSM0020](../diagnostics/ZSM0020.md) — the resulting `MermaidDiagram` +would be empty. + +--- + +## Example: composite with history + +```csharp +[StateMachine(InitialState = nameof(LoadStep.Fetching))] +[Transition(From = LoadStep.Fetching, On = AppTrigger.Tick, To = LoadStep.Parsing)] +[Transition(From = LoadStep.Parsing, On = AppTrigger.Complete, To = LoadStep.Done)] +[Terminal(State = LoadStep.Done)] +public partial class LoadingFsm { } + +[StateMachine(InitialState = nameof(AppState.Idle), Diagram = true)] +[Transition(From = AppState.Idle, On = AppTrigger.Begin, To = AppState.Loading)] +[Transition(From = AppState.Loading, On = AppTrigger.Complete, To = AppState.Ready)] +[Terminal(State = AppState.Ready)] +[CompositeState(State = AppState.Loading, SubMachine = typeof(LoadingFsm))] +[HistoryState(State = AppState.Loading)] +public partial class App { } +``` + +`App.MermaidDiagram` renders as: + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Loading: Begin + Loading --> Ready: Complete + Ready --> [*] + state Loading { + state H as History + [*] --> Fetching + Fetching --> Parsing: Tick + Parsing --> Done: Complete + Done --> [*] + } +``` + +--- + +## Related + +- [States and Triggers](states-and-triggers.md) — the enum surface that names diagram nodes and edges +- [Composite States](composite-states.md) — nested rendering and `[HistoryState]` +- [Concurrent Parts](concurrent-parts.md) — group rendering as side-by-side blocks +- [Attribute Reference — `Diagram`](../attributes.md) +- [ZSM0020](../diagnostics/ZSM0020.md) — `Diagram = true` with no transitions diff --git a/docs/core-concepts/timeout-transitions.md b/docs/core-concepts/timeout-transitions.md index a7128e0..b5e93b4 100644 --- a/docs/core-concepts/timeout-transitions.md +++ b/docs/core-concepts/timeout-transitions.md @@ -65,8 +65,10 @@ w.TryFire(WatchTrigger.Arm); // Idle → Armed; timer armed for 5000ms concurrent). Otherwise the generator emits [ZSM0012](../diagnostics/ZSM0012.md). - `AfterMs` must be strictly positive. `AfterMs = 0` or negative emits [ZSM0013](../diagnostics/ZSM0013.md). -- Timers are **one-shot per arm**. Each entry into the source state arms - the timer; each exit disarms it. There is no interval / repeat mode. +- Timers are **one-shot per arm**. They are armed on construction (if the + initial state owns a timed edge), on every entry into the source state, + and on `Reset()` / `ResetTo(state)`. Each exit disarms them. There is no + interval / repeat mode. --- @@ -78,7 +80,9 @@ For every transition with `AfterMs > 0`, the generator emits: tuple. Allocated on first arm, reused via `Timer.Change(...)` thereafter. 2. **Arm-on-enter** — when the dispatcher writes the source state, it either constructs the timer (first time) or re-arms the existing one to - `AfterMs` milliseconds. + `AfterMs` milliseconds. Initial-state timers are armed by the generated + constructor (via a `private void HookConstructor()` hook) and re-armed by + `Reset()` / `ResetTo(state)`. 3. **Disarm-on-exit** — when a transition leaves the source state, it calls `Timer.Change(Timeout.Infinite, Timeout.Infinite)` on the corresponding field. The timer object is reused, not disposed. @@ -120,29 +124,10 @@ disposable. --- -## Caveats - -**The initial state is not auto-armed.** Timers are armed only when the -generated `TryFire` advances the state machine *into* the source state. If -your `InitialState` is the source of a timed transition — e.g. -`InitialState = nameof(WatchState.Armed)` paired with -`[Transition(From = Armed, On = Timeout, To = Tripped, AfterMs = 5000)]` — -the timer does **not** arm at construction. Two workarounds: - -1. Model the machine so timed states are only ever entered via an explicit - transition (the recommended shape — `Idle → Armed` in the example above). -2. Fire any trigger that lands back on the same state (a self-transition) - immediately after construction to arm the timer on demand. - -A future release may add constructor-time arming for initial-state timers; -until then, prefer option 1. - ---- - ## Related - [Transitions](transitions.md) — the underlying `TryFire` switch model - [Concurrent Mode](concurrent-mode.md) — required by `AfterMs` - [Concurrent Parts](concurrent-parts.md) — multi-machine classes with their own timed edges - [Attribute Reference — `AfterMs`](../attributes.md#transition-afterms) -- [ZSM0012](../diagnostics/ZSM0012.md), [ZSM0013](../diagnostics/ZSM0013.md), [ZSM0019](../diagnostics/ZSM0019.md) +- [ZSM0012](../diagnostics/ZSM0012.md), [ZSM0013](../diagnostics/ZSM0013.md), [ZSM0019](../diagnostics/ZSM0019.md), [ZSM0021](../diagnostics/ZSM0021.md) diff --git a/docs/diagnostics/ZSM0020.md b/docs/diagnostics/ZSM0020.md new file mode 100644 index 0000000..b5e3793 --- /dev/null +++ b/docs/diagnostics/ZSM0020.md @@ -0,0 +1,59 @@ +--- +id: ZSM0020 +title: ZSM0020 — Diagram = true on a Class with No Transitions +sidebar_position: 20 +--- + +# ZSM0020 — Diagram = true on a Class with No Transitions + +**Severity:** Warning + +The class sets `Diagram = true` on `[StateMachine]` (or +`[StateMachineGroup]`) but declares no `[Transition]` attributes. The +generator would emit a `public const string MermaidDiagram` containing only +the `stateDiagram-v2` header and the `[*] --> InitialState` arrow — there's +nothing else to render. Either delete the `Diagram = true` opt-in or add the +transitions you meant to declare. + +## Example + +```csharp +// ZSM0020: Diagram = true with no [Transition] declarations +[StateMachine(InitialState = nameof(S.Idle), Diagram = true)] +public partial class StubMachine { } + +public enum S { Idle, Done } +``` + +For a `[StateMachineGroup]` the same diagnostic fires when no +`[StateMachinePart]` contributes any transitions: + +```csharp +// ZSM0020: group has no transitions across any part +[StateMachineGroup(Diagram = true)] +[StateMachinePart(Name = "Op", InitialState = OpState.Idle)] +public partial class Device { } +``` + +## How to fix + +Either drop the `Diagram = true` opt-in until you actually have edges to +render: + +```csharp +[StateMachine(InitialState = nameof(S.Idle))] +public partial class StubMachine { } +``` + +… or add the transitions that justify the diagram: + +```csharp +[StateMachine(InitialState = nameof(S.Idle), Diagram = true)] +[Transition(From = S.Idle, On = T.Go, To = S.Done)] +[Terminal(State = S.Done)] +public partial class StubMachine { } +``` + +See [Diagram Export](../core-concepts/diagram-export.md) for what the +generator renders, and the [Attribute Reference](../attributes.md) for the +full `Diagram` property surface. diff --git a/docs/diagnostics/ZSM0021.md b/docs/diagnostics/ZSM0021.md new file mode 100644 index 0000000..10584da --- /dev/null +++ b/docs/diagnostics/ZSM0021.md @@ -0,0 +1,72 @@ +--- +id: ZSM0021 +title: ZSM0021 — User Constructor Must Call HookConstructor() +sidebar_position: 21 +--- + +# ZSM0021 — User Constructor Must Call HookConstructor() + +**Severity:** Error + +The class has at least one timed transition (`[Transition(... AfterMs > 0)]`) +**and** declares its own constructor, but that constructor does not call +the generator-emitted `partial void HookConstructor()`. Without that call, +initial-state timers never arm: any timed edge whose `From` is the +`InitialState` would simply sit idle until the machine transitioned in by +some other route. + +When the user owns the constructor, the generator can't inject the arming +call for them — `HookConstructor()` is the explicit handshake that says +"now arm my initial-state timers." + +## Example + +```csharp +// ZSM0021: timed edge from InitialState, user ctor doesn't call HookConstructor() +[StateMachine(InitialState = nameof(WatchState.Armed), Concurrent = true)] +[Transition(From = WatchState.Armed, On = WatchTrigger.Timeout, To = WatchState.Tripped, AfterMs = 5000)] +public partial class Watchdog +{ + private readonly string _name; + + public Watchdog(string name) + { + _name = name; + // missing: HookConstructor(); + } +} +``` + +## How to fix + +Add a `HookConstructor()` call inside the user-declared constructor. The +generator emits it as a `private void HookConstructor()` on the partial; the +body arms every initial-state timer. + +```csharp +[StateMachine(InitialState = nameof(WatchState.Armed), Concurrent = true)] +[Transition(From = WatchState.Armed, On = WatchTrigger.Timeout, To = WatchState.Tripped, AfterMs = 5000)] +public partial class Watchdog +{ + private readonly string _name; + + public Watchdog(string name) + { + _name = name; + HookConstructor(); // arms initial-state timers + } +} +``` + +If you don't need a custom constructor, simply delete it — the generator +emits a default constructor that already calls `HookConstructor()` for you: + +```csharp +[StateMachine(InitialState = nameof(WatchState.Armed), Concurrent = true)] +[Transition(From = WatchState.Armed, On = WatchTrigger.Timeout, To = WatchState.Tripped, AfterMs = 5000)] +public partial class Watchdog { } // generator emits the ctor + HookConstructor() call +``` + +See [Timeout Transitions](../core-concepts/timeout-transitions.md) for the +full timer lifecycle and [ZSM0019](ZSM0019.md) for the related +`Dispose` contract on timed classes. diff --git a/docs/index.md b/docs/index.md index 746265a..3e4ef31 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,6 +51,7 @@ machine.Current; // Pending | [Composite States](core-concepts/composite-states.md) | Hierarchical sub-FSMs, dispatch order, shallow history | | [Timeout Transitions](core-concepts/timeout-transitions.md) | `AfterMs` edges, lazy timers, race-safe CAS, `IDisposable` | | [Concurrent Parts](core-concepts/concurrent-parts.md) | Multiple independent FSMs in one class via `[StateMachineGroup]` | +| [Diagram Export](core-concepts/diagram-export.md) | `Diagram = true` emits a Mermaid `stateDiagram-v2` `const string` | ### Guides @@ -84,3 +85,5 @@ machine.Current; // Pending | [ZSM0017](diagnostics/ZSM0017.md) | Error | `[StateMachineGroup]` declared with no parts | | [ZSM0018](diagnostics/ZSM0018.md) | Error | Composite state inside a group | | [ZSM0019](diagnostics/ZSM0019.md) | Error | Incompatible user-supplied `Dispose` | +| [ZSM0020](diagnostics/ZSM0020.md) | Warning | `Diagram = true` on a class with no transitions | +| [ZSM0021](diagnostics/ZSM0021.md) | Error | User constructor must call `HookConstructor()` | From acc34206640848c46ee5b3d21ff0640c10ab6d73 Mon Sep 17 00:00:00 2001 From: Marcel Roozerkans Date: Sat, 23 May 2026 10:26:35 +0200 Subject: [PATCH 23/23] fix(generator): ZSM0021 now accepts this.HookConstructor() / base.HookConstructor() 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) --- .../StateMachineGenerator.cs | 16 +++++++++++++--- .../DiagnosticTests.cs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs index 539779b..63f1495 100644 --- a/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs +++ b/src/ZeroAlloc.StateMachine.Generator/StateMachineGenerator.cs @@ -654,11 +654,21 @@ private static bool CtorInvokesHookConstructor(IMethodSymbol ctor) var node = syntaxRef.GetSyntax(); if (node is null) continue; - // Walk the ctor body's descendant invocations; look for HookConstructor(). + // Walk the ctor body's descendant invocations; look for HookConstructor(), + // this.HookConstructor(), or base.HookConstructor(). foreach (var inv in node.DescendantNodes().OfType()) { - if (inv.Expression is Microsoft.CodeAnalysis.CSharp.Syntax.IdentifierNameSyntax id && - string.Equals(id.Identifier.ValueText, "HookConstructor", StringComparison.Ordinal)) + var name = inv.Expression switch + { + Microsoft.CodeAnalysis.CSharp.Syntax.IdentifierNameSyntax id => id.Identifier.ValueText, + Microsoft.CodeAnalysis.CSharp.Syntax.MemberAccessExpressionSyntax + { + Expression: Microsoft.CodeAnalysis.CSharp.Syntax.ThisExpressionSyntax or Microsoft.CodeAnalysis.CSharp.Syntax.BaseExpressionSyntax, + Name: Microsoft.CodeAnalysis.CSharp.Syntax.IdentifierNameSyntax memberId + } => memberId.Identifier.ValueText, + _ => null, + }; + if (string.Equals(name, "HookConstructor", StringComparison.Ordinal)) { return true; } diff --git a/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs b/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs index adb56c3..eedf211 100644 --- a/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs +++ b/tests/ZeroAlloc.StateMachine.Generator.Tests/DiagnosticTests.cs @@ -478,6 +478,23 @@ public partial class M { public M(int x) { HookConstructor(); } } +"; + var diags = await TestHelper.GetDiagnostics(source); + Assert.DoesNotContain(diags, d => string.Equals(d.Id, "ZSM0021", StringComparison.Ordinal)); + } + + [Fact] + public async Task ZSM0021_DoesNotFire_When_UserCtor_Calls_ThisHookConstructor() + { + const string source = @" +using ZeroAlloc.StateMachine; +public enum S { A, B } public enum T { Go } +[StateMachine(InitialState = ""A"", Concurrent = true)] +[Transition(From = S.A, On = T.Go, To = S.B, AfterMs = 1000)] +public partial class M +{ + public M(int x) { this.HookConstructor(); } +} "; var diags = await TestHelper.GetDiagnostics(source); Assert.DoesNotContain(diags, d => string.Equals(d.Id, "ZSM0021", StringComparison.Ordinal));