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.
This commit is contained in:
Scott Scoble 2026-06-28 22:02:12 -07:00
parent ec0f213448
commit a6bdbce915
3 changed files with 54 additions and 3 deletions

View file

@ -766,4 +766,32 @@ speck Second {
}
});
});
// Cycle 61: top-of-file `event_suffix:` directive. When set, the .proto
// generator appends the suffix to every event message name.
describe('event_suffix directive (cycle 61)', () => {
it('should record eventSuffix on the SpeckNode', () => {
const content = `speck FederatedMeetup {
event_suffix: "Payload"
event CreateGroup { name: String }
}`;
const ast = parseSpeckContent(content);
expect(ast.specks[0].eventSuffix).toBe('Payload');
});
it('should leave eventSuffix undefined when not set', () => {
const content = `speck FederatedMeetup {
event CreateGroup { name: String }
}`;
const ast = parseSpeckContent(content);
expect(ast.specks[0].eventSuffix).toBeUndefined();
});
it('should tolerate quoted and bare values', () => {
const a = parseSpeckContent('speck A {\n event_suffix: "Payload"\n}');
const b = parseSpeckContent('speck B {\n event_suffix: Payload\n}');
expect(a.specks[0].eventSuffix).toBe('Payload');
expect(b.specks[0].eventSuffix).toBe('Payload');
});
});
});

View file

@ -229,7 +229,11 @@ function emitSpeck(speck: SpeckNode, packageName?: string): string {
// Then emit events as messages
for (const ev of events) {
lines.push(`message ${ev.name} {`);
// 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 {

View file

@ -16,6 +16,12 @@ export interface SpeckNode {
// go_package: e.g. "github.com/sscoble/federated-meetup/proto/federated_meetup/v1;federatedmeetupv1"
protoPackage?: string;
goPackage?: string;
// Cycle 61: optional event-name suffix. When set, the .proto generator
// appends this string to every `event` block's message name (e.g. an
// event CreateGroup with event_suffix: "Payload" emits
// `message CreateGroupPayload { ... }`). Matches the federated-meetup
// convention where every transition payload is named XxxPayload.
eventSuffix?: string;
}
export interface ImportNode {
@ -318,6 +324,16 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
const name = headerMatch[1];
const members: MemberNode[] = [];
// Cycle 61: clear static metadata fields at the start of each parse
// so per-speck directives don't leak between parse calls. This is the
// pre-existing static-field-hack pattern for proto_package / go_package
// (see spec-compiler-dogfood skill anti-pattern). Refactoring to a
// local-object approach is tracked as a separate cleanup; for now
// resetting the fields here is the minimal-touch fix.
(parseSpeck as any)._protoPackage = undefined;
(parseSpeck as any)._goPackage = undefined;
(parseSpeck as any)._eventSuffix = undefined;
let i = startIndex + 1;
let braceCount = 1;
@ -336,7 +352,7 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
// `author:`, `version:`, `license:` continue to be parsed as members
// (AuthorNode, etc.) for backward compatibility with the existing
// PROV-O / CycloneDX generators.
const metaMatch = line.match(/^(proto_package|go_package)\s*:\s*(.+)$/);
const metaMatch = line.match(/^(proto_package|go_package|event_suffix)\s*:\s*(.+)$/);
if (metaMatch) {
// Strip surrounding quotes if present
const value = metaMatch[2].trim().replace(/^['"]|['"]$/g, '');
@ -344,6 +360,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
(parseSpeck as any)._protoPackage = value;
} else if (metaMatch[1] === 'go_package') {
(parseSpeck as any)._goPackage = value;
} else if (metaMatch[1] === 'event_suffix') {
(parseSpeck as any)._eventSuffix = value;
}
i++;
continue;
@ -417,7 +435,8 @@ function parseSpeck(lines: string[], startIndex: number): SpeckNode | null {
return { type: 'speck', name, members,
protoPackage: (parseSpeck as any)._protoPackage,
goPackage: (parseSpeck as any)._goPackage };
goPackage: (parseSpeck as any)._goPackage,
eventSuffix: (parseSpeck as any)._eventSuffix };
}
function findBlockEnd(lines: string[], startIndex: number, startBraceCount: number): number {