From b65bf0e2062e17db5e169bc56bfdcc1dd50bf506 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Sun, 28 Jun 2026 21:53:38 -0700 Subject: [PATCH] feat(speckl): transition envelope (cycle 58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'transition Name { ... }' as a top-level member of a speck block. The AST node is TransitionNode with fields[] plus optional payloadOneof and typeDiscriminator references. Two field names have special meaning: payload: → the generator will emit the referenced oneof's variants inline as 'oneof payload { ... }' inside the message body (i.e. payload becomes a proto oneof, not a regular field) type: → the discriminator enum for the variants All other fields are emitted as ordinary proto message fields. This is the shape federated-meetup needs for its Transition message: message Transition { TransitionType type = 1; StateRoot prior_state = 2; oneof payload { CreateGroupPayload create_group = 10; ... } Multisig steward_signatures = 31; bytes hlc = 32; fixed32 branch_id = 33; google.protobuf.Timestamp signed_at = 30; } The next cycle (64) will wire the .proto generator to emit this shape from a TransitionNode. Cycle 58 is parser-only — 3 new tests cover regular fields, payload+type references, and trailing punctuation tolerance. All 46 tests (39 baseline + 4 oneof + 3 transition) pass. tsc build clean. --- compiler/__tests__/parser.test.ts | 62 +++++++++++++++++++++ compiler/src/parser.ts | 91 ++++++++++++++++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/compiler/__tests__/parser.test.ts b/compiler/__tests__/parser.test.ts index 375341c..f1fb959 100644 --- a/compiler/__tests__/parser.test.ts +++ b/compiler/__tests__/parser.test.ts @@ -646,4 +646,66 @@ speck Second { expect(members.filter(m => m.type === 'oneof')).toHaveLength(1); }); }); + + // Cycle 58: transition envelope — a proto message wrapping a oneof payload + // plus metadata fields. Federated-meetup's `Transition` message is the + // canonical example: prior_state, payload (oneof), steward_signatures, + // hlc, branch_id, signed_at. + describe('transition blocks (cycle 58)', () => { + it('should parse a minimal transition with regular fields', () => { + const content = `speck One { + transition Transition { + hlc: Bytes + branch_id: Nat + } +}`; + const ast = parseSpeckContent(content); + const t = ast.specks[0].members.find(m => m.type === 'transition'); + expect(t).toBeDefined(); + if (t?.type === 'transition') { + expect(t.name).toBe('Transition'); + expect(t.fields.map(f => f.name)).toEqual(['hlc', 'branch_id']); + expect(t.payloadOneof).toBeUndefined(); + expect(t.typeDiscriminator).toBeUndefined(); + } + }); + + it('should recognize payload: and record the reference', () => { + const content = `speck One { + oneof TransitionPayload { + create_group: CreateGroupPayload + } + transition Transition { + type: TransitionType + payload: TransitionPayload + hlc: Bytes + } +}`; + const ast = parseSpeckContent(content); + const t = ast.specks[0].members.find(m => m.type === 'transition'); + if (t?.type === 'transition') { + expect(t.payloadOneof).toBe('TransitionPayload'); + // payload must NOT appear in fields + expect(t.fields.map(f => f.name)).toEqual(['hlc']); + // type is a discriminator reference, not a regular field + expect(t.typeDiscriminator).toBe('TransitionType'); + expect(t.fields.find(f => f.name === 'type')).toBeUndefined(); + } + }); + + it('should tolerate trailing commas and comments', () => { + const content = `speck One { + transition Transition { + // timestamp from the signer + signed_at: Timestamp, + hlc: Bytes; + } +}`; + const ast = parseSpeckContent(content); + const t = ast.specks[0].members.find(m => m.type === 'transition'); + if (t?.type === 'transition') { + expect(t.fields.map(f => f.name)).toEqual(['signed_at', 'hlc']); + } + }); + }); }); diff --git a/compiler/src/parser.ts b/compiler/src/parser.ts index 863dc2c..c7200aa 100644 --- a/compiler/src/parser.ts +++ b/compiler/src/parser.ts @@ -110,6 +110,26 @@ export interface OneofVariant { typeName: string; // referenced type, e.g. "CreateGroup" } +// Cycle 58: Transition envelope — a proto message that wraps a oneof payload +// along with metadata fields (prior_state, signatures, hlc, branch_id, etc). +// Distinct from `event` because: +// - payload field references a OneofNode (not a regular message type) +// - generator emits a `oneof payload { ... }` block inside the message body +// - the discriminator field uses a proto enum (TransitionType) +// This is the shape federated-meetup needs for its `Transition` message. +export interface TransitionNode { + type: 'transition'; + name: string; + fields: TransitionField[]; + payloadOneof?: string; // name of the OneofNode whose variants go inside the oneof payload block + typeDiscriminator?: string; // name of the enum used to discriminate variants +} + +export interface TransitionField { + name: string; + type: TypeExpr; +} + export interface ProvenanceNode { type: 'provenance'; clauses: ProvenanceClause[]; @@ -203,6 +223,7 @@ export type MemberNode = | InterfaceNode | EventNode | OneofNode + | TransitionNode | ProvenanceNode | ReviewNode | DerivesNode @@ -325,7 +346,7 @@ 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 ']; + 'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service ', 'oneof ', 'transition ']; const isBlockStarter = blockStarters.some(s => line.startsWith(s)); if (isBlockStarter && openCount > 0) { @@ -451,6 +472,9 @@ function parseMemberBlock(lines: string[], startIndex: number): MemberNode | nul if (firstLine.startsWith('oneof ')) { return parseOneofBlockMultiline(lines, startIndex, startBraceCount); } + if (firstLine.startsWith('transition ')) { + return parseTransitionBlockMultiline(lines, startIndex, startBraceCount); + } if (firstLine.startsWith('service ')) { return parseServiceBlockMultiline(lines, startIndex, startBraceCount); } @@ -581,6 +605,11 @@ function parseMember(line: string): MemberNode | null { if (line.startsWith('oneof ')) { return parseOneof(line); } + + // transition: + if (line.startsWith('transition ')) { + return parseTransition(line); + } // provenance { if (line.startsWith('provenance ')) { @@ -927,6 +956,20 @@ function parseOneof(line: string): OneofNode { }; } +// Cycle 58: parse `transition Name {` (header only; parseTransitionBlockMultiline fills fields + payload-oneof). +function parseTransition(line: string): TransitionNode { + // transition Name { + const match = line.match(/^transition\s+(\w+)\s*{/); + if (!match) { + throw new Error(`Failed to parse transition: ${line}`); + } + return { + type: 'transition', + name: match[1], + fields: [], + }; +} + function parseProvenanceBlock(lines: string[], line: string): ProvenanceNode { // This is a simplified parser - in a real implementation, we'd parse the full block return { @@ -1307,6 +1350,52 @@ function parseOneofBlockMultiline(lines: string[], startIndex: number, startBrac return { ...header, variants }; } +// Cycle 58: parse a `transition Name { ... }` block. Each non-empty, +// non-comment inner line is `field_name: TypeName`. Two field names +// have special meaning: +// - `payload: ` — references a sibling `oneof` block; +// the proto generator will emit the variants inline as +// `oneof payload { ... }` inside the message body +// - `type: ` — the discriminator enum for the variants +// Other fields are emitted as ordinary proto message fields. +function parseTransitionBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): TransitionNode { + const firstLine = lines[startIndex].trim(); + const header = parseTransition(firstLine); + const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount); + const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*')); + + const fields: TransitionField[] = []; + let payloadOneof: string | undefined; + let typeDiscriminator: string | undefined; + + for (const line of innerLines) { + const trimmed = line.replace(/[,;]\s*$/, ''); + const m = trimmed.match(/^(\w+)\s*:\s*(.+)$/); + if (!m) continue; + const fieldName = m[1]; + const typeStr = m[2].trim(); + + if (fieldName === 'payload') { + // The type is the name of a sibling oneof block. + payloadOneof = typeStr; + // Don't add a regular field for payload — it becomes the oneof. + continue; + } + if (fieldName === 'type') { + // The type is the name of a discriminator enum. + typeDiscriminator = typeStr; + // Don't add a regular field for type — it's a special discriminator. + continue; + } + fields.push({ name: fieldName, type: parseTypeExpr(typeStr) }); + } + + const result: TransitionNode = { ...header, fields }; + if (payloadOneof !== undefined) result.payloadOneof = payloadOneof; + if (typeDiscriminator !== undefined) result.typeDiscriminator = typeDiscriminator; + return result; +} + function parseServiceBlockMultiline(lines: string[], startIndex: number, startBraceCount: number): ServiceNode { // service Name { const firstLine = lines[startIndex].trim();