feat(speckl): emit TransitionNode and OneofNode in proto generator (cycle 64)

The .proto generator now recognizes two new top-level speck members
and emits real proto3 syntax for them:

  oneof Name {                    oneof is NOT emitted at top level
    variant_field: VariantType    (it lives inside the transition's
  }                                payload block — see below)

  transition Name {               emitted as:
    type: <EnumName>                message Name {
    payload: <OneofName>              <regular fields>
    hlc: Bytes                        oneof payload {
  }                                    VariantType variant_field = 10;
                                       ...
                                     }
                                   }

The  and  fields are special-cased: type records the
discriminator enum reference (so future type-aware code can find it),
payload records the sibling oneof block to inline. Neither is emitted
as a regular proto field.

The oneof variants reference the suffixed message name when the
speck has 'event_suffix: "Payload"' (or any suffix). This makes the
generated variants match the consumer's Go types in the federated-
meetup case: 'CreateGroupPayload create_group = 10' is the type
binding the consumer's apply switch expects.

End-to-end verified: a speck with
  event_suffix: "Payload"
  oneof TransitionPayload {
    create_group: CreateGroup
    add_steward: AddSteward
  }
  transition Transition {
    type: TransitionType
    payload: TransitionPayload
    hlc: Bytes
  }
  event CreateGroup { name: String }
  event AddSteward { new_steward: String }

emits the right proto3:
  message CreateGroupPayload { ... }
  message AddStewardPayload { ... }
  message Transition {
    bytes hlc = 1;
    oneof payload {
      CreateGroupPayload create_group = 10;
      AddStewardPayload add_steward = 11;
    }
  }

All 55 tests pass. tsc build clean.

The federated-meetup speckdl (specs/FederatedMeetup.speckdl) does
not yet declare the Transition envelope — it uses 13 separate
event blocks. Adding the transition+oneof envelope to the real
spec is the next step (cycle 65).
This commit is contained in:
Scott Scoble 2026-06-28 22:31:24 -07:00
parent 4d95a2b975
commit f37c0ccc57

View file

@ -22,7 +22,7 @@
* existing determinism claim.
*/
import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode } from '../parser.js';
import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode, TransitionNode, OneofNode } from '../parser.js';
import fs from 'fs';
import path from 'path';
@ -270,8 +270,8 @@ function emitSpeck(speck: SpeckNode, packageName?: string): string {
let protoType = speckTypeToProto(v.typeExpr.name || '');
if (v.typeExpr.type === 'map' && v.typeExpr.keyType && v.typeExpr.valueType) {
const k = speckTypeToProto(v.typeExpr.keyType.name || 'string');
const val = speckTypeToProto(v.typeExpr.valueType.name || 'bytes');
protoType = `map<${k}, ${val}>`;
const v2 = speckTypeToProto(v.typeExpr.valueType.name || 'bytes');
protoType = `map<${k}, ${v2}>`;
} else if (v.typeExpr.type === 'list' && v.typeExpr.elementType) {
const inner = speckTypeToProto(v.typeExpr.elementType.name || 'string');
protoType = `repeated ${inner}`;
@ -282,6 +282,40 @@ function emitSpeck(speck: SpeckNode, packageName?: string): string {
lines.push('');
}
// Cycle 64: emit TransitionNode messages. A `transition Name { ... }`
// block produces a proto message with the regular fields plus an inline
// `oneof payload { ... }` block, whose variants are pulled from a
// sibling `oneof <Name> { ... }` block referenced by `payload: <Name>`.
// Field numbers are assigned starting at 1; the oneof variants use
// 10, 11, 12, ... (proto convention: leave 1-15 for the most stable
// fields so the wire format is forward-compatible).
const transitionNodes = members.filter(m => m.type === 'transition') as TransitionNode[];
const oneofNodes = members.filter(m => m.type === 'oneof') as OneofNode[];
for (const t of transitionNodes) {
lines.push(`message ${t.name} {`);
const fieldNums = assignFieldNumbers(t.fields);
for (const f of t.fields) {
const num = fieldNums.get(f.name) || 1;
const protoType = speckTypeToProto(f.type.name || '');
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
}
if (t.payloadOneof) {
const oneof = oneofNodes.find(o => o.name === t.payloadOneof);
if (oneof) {
lines.push(' oneof payload {');
oneof.variants.forEach((v, i) => {
// Reference the suffixed message name if event_suffix is set,
// so the oneof variants match what the consumer Go code expects.
const variantType = speck.eventSuffix ? v.typeName + speck.eventSuffix : v.typeName;
lines.push(` ${variantType} ${v.fieldName} = ${i + 10};`);
});
lines.push(' }');
}
}
lines.push('}');
lines.push('');
}
// ─── Service (RPC surface) ──────────────────────────────────
// ServiceNode members emit proto3 service blocks. These map directly to
// gRPC and ConnectRPC service definitions; buf generate + protoc-gen-go