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:
parent
0e42961525
commit
b65bf0e206
2 changed files with 152 additions and 1 deletions
|
|
@ -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']);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue