From 4d95a2b9752425e3f4c25e359c550c520f9b32c6 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Sun, 28 Jun 2026 22:29:28 -0700 Subject: [PATCH] fix(speckl): dispatch 'state as ' to parseStateBlockMultiline (cycle 63) End-to-end compile of FederatedMeetup.speckdl with the new 'state as StateSnapshot' header (cycle 63) surfaced that the parser's main loop and parseMemberBlock dispatcher were both missing 'state as ' as a recognized block-starter prefix. The state-block parser (parseStateBlockMultiline) already extracts the 'as' clause correctly (cycle 59), but the call site never reached it: the block fell through the 'state:' / 'state {' checks and was parsed as a regular member line, producing no StateNode at all. Fix: add 'state as ' to the blockStarters list in the main parse loop, and add a parallel dispatch in parseMemberBlock that routes 'state as ' headers to parseStateBlockMultiline. The dispatcher change is symmetric with the existing 'state:' and 'state {' forms. Tests: 1 new parser test confirms the dispatch works for 'state as StateSnapshot { ... }'. All 55 tests pass (54 baseline + 1 new). tsc build clean. End-to-end verified: running generateProtobuf against the federated-meetup speck with 'state as StateSnapshot' now emits 'message StateSnapshot { ... }' (correct), where pre-fix the StateNode was lost entirely. --- compiler/__tests__/parser.test.ts | 20 ++++++++++++++++++++ compiler/src/parser.ts | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/compiler/__tests__/parser.test.ts b/compiler/__tests__/parser.test.ts index e56a0eb..d4d5e33 100644 --- a/compiler/__tests__/parser.test.ts +++ b/compiler/__tests__/parser.test.ts @@ -833,4 +833,24 @@ speck Second { } }); }); + + // Cycle 63: 'state as { ... }' must dispatch through the + // same parseStateBlockMultiline path as 'state: { ... }'. Cycle 59 + // added the parser-side extraction of the `as` clause; this test + // confirms the block-dispatcher also routes the new header form. + describe('state as dispatch (cycle 63)', () => { + it('should dispatch state as StateSnapshot to parseStateBlockMultiline', () => { + const content = `speck FederatedMeetup { + state as StateSnapshot { + branch_id: Nat + } +}`; + const ast = parseSpeckContent(content); + const state = ast.specks[0].members.find(m => m.type === 'state'); + if (state?.type === 'state') { + expect(state.messageName).toBe('StateSnapshot'); + expect(state.variables.map(v => v.name)).toEqual(['branch_id']); + } + }); + }); }); diff --git a/compiler/src/parser.ts b/compiler/src/parser.ts index 45f1dd8..1e3d8a8 100644 --- a/compiler/src/parser.ts +++ b/compiler/src/parser.ts @@ -373,7 +373,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null { // Check for block-opening members (those that open braces on the same line) const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ', - 'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service ', 'oneof ', 'transition ']; + 'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service ', 'oneof ', 'transition ', + 'state as ']; const isBlockStarter = blockStarters.some(s => line.startsWith(s)); if (isBlockStarter && openCount > 0) { @@ -482,6 +483,12 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul if (firstLine.startsWith('state {') || firstLine === 'state') { return parseStateBlockMultiline(lines, startIndex, startBraceCount); } + if (firstLine.startsWith('state as ')) { + // Cycle 63: `state as { ... }` form must dispatch + // through the same state-block parser so the `as` override on the + // first line is captured. + return parseStateBlockMultiline(lines, startIndex, startBraceCount); + } if (firstLine.startsWith('init:')) { return parseInitBlockMultiline(lines, startIndex, startBraceCount); }