feat(speckl): transition envelope (cycle 58)

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: <OneofName>  →  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:    <EnumName>   →  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.
This commit is contained in:
Scott Scoble 2026-06-28 21:53:38 -07:00
parent 0e42961525
commit b65bf0e206
2 changed files with 152 additions and 1 deletions

View file

@ -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: <OneofName> 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']);
}
});
});
});

View file

@ -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: <OneofName>` — references a sibling `oneof` block;
// the proto generator will emit the variants inline as
// `oneof payload { ... }` inside the message body
// - `type: <EnumName>` — 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();