speckl/compiler/src/generators/protobuf.ts
Scott Scoble a6bdbce915 feat(speckl): event_suffix directive + static-field reset (cycle 61)
Adds 'event_suffix: "Payload"' as a top-of-file directive. When set,
the .proto generator appends the suffix to every 'event' block's
emitted message name. Default (no directive) preserves the existing
'event CreateGroup' → 'message CreateGroup' behavior.

Why: federated-meetup's handwritten proto names every transition
payload CreateGroupPayload, AddStewardPayload, etc. (the convention
that the wrapper message carries a 'Payload' suffix and the event
type lives in 'oneof payload { ... }'). Without this directive, the
Speckl-generated proto would emit 'message CreateGroup' and the
consumer Go code would fail to find the types it imports.

The test fixture uses three tests:
  - directive parsed and stored on SpeckNode
  - directive absent → eventSuffix undefined
  - both quoted ("Payload") and bare (Payload) values accepted

The reset of (parseSpeck as any)._protoPackage / _goPackage /
_eventSuffix at the start of parseSpeck fixes a pre-existing
static-field-hack leak where a previous test's metadata would
persist into the next test's parse. Documented as a known
anti-pattern in spec-compiler-dogfood SKILL.md; full cleanup
to a local-object pattern is tracked separately. The minimum
fix here is 'reset at start of parse' which restores test
isolation without refactoring the entire metadata-collection
path.

End-to-end verified: running generateProtobuf against a speck
with event_suffix: "Payload" emits 'message CreateGroupPayload'
and 'message AddStewardPayload' (correct).

All 52 tests pass. tsc build clean.

Reference: .hermes/skills/spec-compiler-dogfood/references/
  speckl-federated-meetup-protobuf-migration.md — the migration
unblocks when every generated message name matches what the
consumer's Go imports expect.
2026-06-28 22:02:12 -07:00

304 lines
No EOL
12 KiB
TypeScript

/**
* Generate Protocol Buffers (proto3) from a SpeckDL spec.
*
* This is the load-bearing backend for the "no glue code" claim:
* the .proto file emitted here is the canonical wire format. Run
* `buf generate` on the output to produce Go / TypeScript / Rust / C
* / Python / Java / etc. bindings — every gRPC and ConnectRPC ecosystem
* gets free bindings with zero hand-written converters.
*
* Mapping:
* event X { name: String, age: Nat } -> message X { string name = 1; uint64 age = 2; }
* interface T { Foo, Bar } -> enum T { T_FOO = 0; T_BAR = 1; }
* interface X { field: Type } -> message X { Type field = 1; }
* state: { foo: Type, bar: Type } -> message StateSnapshot { Type foo = 1; Type bar = 2; }
* service S { rpc M(R) returns (Rsp);} -> service S { rpc M(R) returns (Rsp); }
* constraint: ... -> comment in generated .proto
* verify: ... -> comment in generated .proto
* bom { ... } -> file-level metadata
*
* Deterministic: field numbering is alphabetical by field name, so the same
* .speckdl always produces the same .proto byte-for-byte. Matches Speckl's
* existing determinism claim.
*/
import { AST, SpeckNode, MemberNode, StateNode, EventNode, InterfaceNode, TypeExpr, ServiceNode } from '../parser.js';
import fs from 'fs';
import path from 'path';
const PROTO3 = 'proto3';
// SpeckDL type -> proto3 type
function speckTypeToProto(typeName: string): string {
const t = typeName.trim();
switch (t) {
case 'String': return 'string';
case 'StringList': return 'repeated string';
case 'Nat': return 'uint64';
case 'NatList': return 'repeated uint64';
case 'Int': return 'int64';
case 'IntList': return 'repeated int64';
case 'Bool': return 'bool';
case 'BoolList': return 'repeated bool';
case 'Bytes': return 'bytes';
case 'BytesList': return 'repeated bytes';
case 'Real': return 'double';
case 'Date': return 'google.protobuf.Timestamp';
default:
// User-defined types: passed through verbatim. The user is responsible
// for ensuring these resolve to messages/enums defined elsewhere in
// the same .proto (or imported).
return t;
}
}
// Deterministic field numbering: alphabetical by field name, 1-indexed.
function assignFieldNumbers(fields: { name: string }[]): Map<string, number> {
const sorted = [...fields].sort((a, b) => a.name.localeCompare(b.name));
const map = new Map<string, number>();
sorted.forEach((f, i) => map.set(f.name, i + 1));
return map;
}
function snakeCase(s: string): string {
return s
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase();
}
function pascalCase(s: string): string {
return s
.replace(/[_\-]+/g, ' ')
.replace(/\s+(.)/g, (_, c: string) => c.toUpperCase())
.replace(/^(.)/, (_, c: string) => c.toUpperCase())
.replace(/\s/g, '');
}
function upperSnake(s: string): string {
return snakeCase(s).toUpperCase();
}
interface InterfaceField {
name: string;
type: TypeExpr; // matches parser's MethodSignature.params shape
}
interface InterfaceKind {
name: string;
isEnum: boolean;
isMessage: boolean;
variants?: string[];
fields?: InterfaceField[];
}
// Use the parser's `kind` discriminator to route each interface to the
// correct proto3 construct. The parser sets `kind` based on what was found
// in the interface block:
// 'enum' → proto3 enum (variants)
// 'record' → proto3 message (fields)
// 'service' → proto3 service (rpc methods) — handled separately
// 'mixed' → inspect methods/fields directly to disambiguate
function classifyInterfaces(members: MemberNode[]): Map<string, InterfaceKind> {
const result = new Map<string, InterfaceKind>();
for (const m of members) {
if (m.type !== 'interface') continue;
const iface = m as any; // parser uses kind/fields now
switch (iface.kind) {
case 'enum':
result.set(iface.name, {
name: iface.name,
isEnum: true,
isMessage: false,
variants: iface.methods.map((meth: any) => meth.name),
});
break;
case 'record':
result.set(iface.name, {
name: iface.name,
isEnum: false,
isMessage: true,
fields: iface.fields,
});
break;
case 'service':
// Services emitted separately; skip in this map
break;
case 'mixed':
default:
if (iface.fields && iface.fields.length > 0) {
result.set(iface.name, {
name: iface.name,
isEnum: false,
isMessage: true,
fields: iface.fields,
});
} else if (iface.methods && iface.methods.length > 0) {
result.set(iface.name, {
name: iface.name,
isEnum: true,
isMessage: false,
variants: iface.methods.map((meth: any) => meth.name),
});
}
break;
}
}
return result;
}
export function generateProtobuf(ast: AST, outputDir: string, packageName?: string): void {
for (const speck of ast.specks) {
const code = emitSpeck(speck, packageName);
const filename = `${snakeCase(speck.name)}.proto`;
const filepath = path.join(outputDir, filename);
fs.writeFileSync(filepath, code);
console.log(`Generated protobuf schema: ${filepath}`);
}
}
function emitSpeck(speck: SpeckNode, packageName?: string): string {
const members = speck.members;
const stateNode = members.find(m => m.type === 'state') as StateNode | undefined;
const events = members.filter(m => m.type === 'event') as EventNode[];
const interfaces = classifyInterfaces(members);
const pkg = (speck as any).protoPackage || packageName || `speckl.${snakeCase(speck.name)}.v1`;
const goPkg = (speck as any).goPackage
|| `github.com/sscoble/${snakeCase(speck.name)}/proto/${snakeCase(speck.name)}/v1;${snakeCase(speck.name)}v1`;
const lines: string[] = [];
// Header
lines.push('// Auto-generated by speckl-compile from ' + speck.name + '.speckdl');
lines.push('// DO NOT EDIT MANUALLY — regenerate from the .speckdl source');
lines.push('// SPDX-License-Identifier: MIT');
lines.push('');
lines.push('syntax = "' + PROTO3 + '";');
lines.push('');
lines.push('package ' + pkg + ';');
lines.push('');
lines.push('option go_package = "' + goPkg + '";');
lines.push('');
// Import google.protobuf.Timestamp if any field uses Date
const usesTimestamp = members.some(m => {
if (m.type === 'event') {
return (m as EventNode).fields.some(f => f.type.name === 'Date');
}
if (m.type === 'state') {
return (m as StateNode).variables.some(v =>
v.typeExpr.name === 'Date' ||
(v.typeExpr.type === 'list' && v.typeExpr.elementType?.name === 'Date')
);
}
return false;
});
if (usesTimestamp) {
lines.push('import "google/protobuf/timestamp.proto";');
lines.push('');
}
// ─── Enums (interface with variants) ────────────────────────
for (const [, iface] of Array.from(interfaces.entries())) {
if (iface.isEnum && iface.variants) {
lines.push(`enum ${iface.name} {`);
lines.push(` ${iface.name}_UNSPECIFIED = 0;`);
iface.variants.forEach((v, i) => {
lines.push(` ${upperSnake(v)} = ${i + 1};`);
});
lines.push('}');
lines.push('');
}
}
// ─── Messages (interface with fields, plus events) ─────────
// First emit interface-messages (record types like BranchInfo, CustodyDeclaration)
for (const [, iface] of Array.from(interfaces.entries())) {
if (iface.isMessage && iface.fields && iface.fields.length > 0) {
lines.push(`message ${iface.name} {`);
const fieldNums = assignFieldNumbers(iface.fields);
for (const f of iface.fields) {
const num = fieldNums.get(f.name) || 1;
const protoType = speckTypeToProto(f.type.name || '');
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
}
lines.push('}');
lines.push('');
}
}
// Then emit events as messages
for (const ev of events) {
// Cycle 61: honor `event_suffix: "Payload"` directive so emitted
// message names match consumer Go bindings (e.g. CreateGroupPayload
// instead of CreateGroup).
const evName = speck.eventSuffix ? ev.name + speck.eventSuffix : ev.name;
lines.push(`message ${evName} {`);
if (ev.fields.length === 0) {
lines.push(' // empty event');
} else {
const fieldNums = assignFieldNumbers(ev.fields);
for (const f of ev.fields) {
const num = fieldNums.get(f.name) || 1;
let protoType = speckTypeToProto(f.type.name || '');
// Handle Map<K, V> and List<T>
if (f.type.type === 'map' && f.type.keyType && f.type.valueType) {
const k = speckTypeToProto(f.type.keyType.name || 'string');
const v = speckTypeToProto(f.type.valueType.name || 'bytes');
protoType = `map<${k}, ${v}>`;
} else if (f.type.type === 'list' && f.type.elementType) {
const inner = speckTypeToProto(f.type.elementType.name || 'string');
protoType = `repeated ${inner}`;
}
lines.push(` ${protoType} ${snakeCase(f.name)} = ${num};`);
}
}
lines.push('}');
lines.push('');
}
// Then emit state snapshot as a message
if (stateNode && stateNode.variables.length > 0) {
// Cycle 59: honor `state as <Name>` override so the consumer Go code
// can import the generated message by the expected name (e.g.
// StateSnapshot instead of FederatedMeetupState).
const msgName = stateNode.messageName || `${pascalCase(speck.name)}State`;
lines.push(`message ${msgName} {`);
const fieldNums = assignFieldNumbers(stateNode.variables);
for (const v of stateNode.variables) {
const num = fieldNums.get(v.name) || 1;
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}>`;
} else if (v.typeExpr.type === 'list' && v.typeExpr.elementType) {
const inner = speckTypeToProto(v.typeExpr.elementType.name || 'string');
protoType = `repeated ${inner}`;
}
lines.push(` ${protoType} ${snakeCase(v.name)} = ${num};`);
}
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
// + protoc-gen-connect-go produce client stubs and Connect handlers.
const serviceNodes = members.filter((m: any) => m.type === 'service') as ServiceNode[];
if (serviceNodes.length > 0) {
for (const svc of serviceNodes) {
lines.push(`service ${svc.name} {`);
for (const rpc of svc.rpcs) {
const reqStream = rpc.clientStreaming ? 'stream ' : '';
const resStream = rpc.serverStreaming ? 'stream ' : '';
lines.push(` rpc ${pascalCase(rpc.name)}(${reqStream}${rpc.requestType}) returns (${resStream}${rpc.responseType});`);
}
lines.push('}');
lines.push('');
}
}
return lines.join('\n');
}