Skip to content

Commit dd28159

Browse files
committed
test: lock v1 serialized wire shapes and public API signatures
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 58e529c commit dd28159

2 files changed

Lines changed: 272 additions & 0 deletions

File tree

state/interface_freeze_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package state
22

3+
import "context"
4+
35
// This file pins the v1.0 frozen interface surface with compile-time
46
// assertions. If a freeze is violated — a sealed interface gains a method its
57
// sole crucible implementer does not satisfy, or a host-implementable interface
@@ -21,3 +23,46 @@ var (
2123
_ Snapshotter = (*actorAdapter[int, int, int])(nil)
2224
_ ActorInstance = (*actorAdapter[int, int, int])(nil)
2325
)
26+
27+
// ---------------------------------------------------------------------------
28+
// v1.0 API signature freeze.
29+
//
30+
// The following compile-time assignments pin the EXACT signatures of the
31+
// load-bearing public constructors and methods that the v1 promise covers. If
32+
// any signature drifts — a parameter type changes, an option tail is dropped, a
33+
// return type changes — the package stops compiling here. This is deliberate: a
34+
// public signature change is a breaking change and must be a conscious decision,
35+
// not an accident caught only downstream.
36+
//
37+
// HOW TO UPDATE (deliberately, additive only): these constructors and methods
38+
// already end in a variadic functional-option tail, so a new capability arrives
39+
// as a new option WITHOUT changing any signature below — no edit needed. Edit a
40+
// pinned signature here only when intentionally making a breaking API change,
41+
// which requires a major version bump.
42+
//
43+
// Only genuinely load-bearing surface is pinned; internal helpers are left free
44+
// to evolve. Signatures are instantiated at concrete int/int/int (or string)
45+
// type params, which does not change the shape being frozen.
46+
var (
47+
// Forge / ForgeFor — the two builder entry points.
48+
_ func(string, ...ForgeOption) *Builder[int, int, int] = Forge[int, int, int]
49+
_ func(string, ...ForgeOption) *Builder[string, string, int] = ForgeFor[int]
50+
51+
// Quench / Cast — builder -> machine -> instance.
52+
_ func(*Builder[int, int, int], ...QuenchOption) *Machine[int, int, int] = (*Builder[int, int, int]).Quench
53+
_ func(*Machine[int, int, int], int, ...CastOption[int]) *Instance[int, int, int] = (*Machine[int, int, int]).Cast
54+
55+
// Fire — the pure step, on Instance.
56+
_ func(*Instance[int, int, int], context.Context, int, ...FireOption) FireResult[int] = (*Instance[int, int, int]).Fire
57+
58+
// IR serialization round-trip.
59+
_ func([]byte, ...LoadOption) (*IR[int, int, int], error) = LoadFromJSON[int, int, int]
60+
_ func(*Machine[int, int, int], ...ToJSONOption) ([]byte, error) = (*Machine[int, int, int]).ToJSON
61+
62+
// Palette — registry introspection.
63+
_ func(*Registry[int]) []Descriptor = (*Registry[int]).Palette
64+
65+
// Visualization exporters.
66+
_ func(*Machine[int, int, int], ...VizOption) string = (*Machine[int, int, int]).ToMermaid
67+
_ func(*Machine[int, int, int], ...VizOption) string = (*Machine[int, int, int]).ToDOT
68+
)

state/wirefreeze_test.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,238 @@ package state_test
22

33
import (
44
"encoding/json"
5+
"reflect"
56
"testing"
67

78
"github.com/stablekernel/crucible/state"
89
)
910

11+
// fieldShape is one exported field of a serialized struct, captured as its Go
12+
// field NAME and its full `json:` struct tag. Together these are the wire
13+
// contract a recorded document depends on, so the frozen expectation pins both.
14+
type fieldShape struct {
15+
Name string
16+
JSONTag string
17+
}
18+
19+
// reflectShape walks the exported fields of a struct type and returns each
20+
// field's name and its `json:` tag, in declaration order. Unexported fields
21+
// (e.g. the `extra` round-trip buffer) carry no wire shape and are skipped, so
22+
// the frozen expectation pins exactly the bytes that cross the wire.
23+
func reflectShape(t *testing.T, v any) []fieldShape {
24+
t.Helper()
25+
rt := reflect.TypeOf(v)
26+
if rt.Kind() != reflect.Struct {
27+
t.Fatalf("reflectShape: %T is not a struct", v)
28+
}
29+
var out []fieldShape
30+
for i := 0; i < rt.NumField(); i++ {
31+
f := rt.Field(i)
32+
if f.PkgPath != "" { // unexported: no wire shape
33+
continue
34+
}
35+
out = append(out, fieldShape{Name: f.Name, JSONTag: f.Tag.Get("json")})
36+
}
37+
return out
38+
}
39+
40+
// assertShape compares a serialized type's reflected exported-field shape against
41+
// the frozen expectation, field by field, and fails on any rename, removal,
42+
// reorder, retag, or unexpected addition.
43+
func assertShape(t *testing.T, name string, got, want []fieldShape) {
44+
t.Helper()
45+
if len(got) != len(want) {
46+
t.Fatalf("%s field count = %d, want %d (frozen v1 wire shape)\n got: %+v\nwant: %+v",
47+
name, len(got), len(want), got, want)
48+
}
49+
for i := range want {
50+
if got[i] != want[i] {
51+
t.Fatalf("%s field[%d] = {Name:%q JSONTag:%q}, want {Name:%q JSONTag:%q} (frozen v1 wire shape)",
52+
name, i, got[i].Name, got[i].JSONTag, want[i].Name, want[i].JSONTag)
53+
}
54+
}
55+
}
56+
57+
// TestWireShape_Frozen is the v1.0 WIRE FREEZE guard for the serialized IR and
58+
// palette structs. It reflects over each serialized type and pins the exact set,
59+
// order, NAMES, and `json:` tags (including the omitempty flag) of its exported
60+
// fields. A recorded document is parsed by these names and tags, so renaming,
61+
// removing, reordering, or retagging any field is a breaking wire change and must
62+
// break this test.
63+
//
64+
// HOW TO UPDATE (deliberately, additive only): appending a new optional field
65+
// with an `omitempty` tag is a backward-compatible (minor) change — add the new
66+
// {Name, JSONTag} entry to the END of the relevant want slice in the same order
67+
// the struct declares it. Any rename/removal/reorder/retag is a breaking (major)
68+
// change and the test SHOULD fail until the wire-format version is bumped
69+
// deliberately. The `extra` unexported round-trip buffer carries no wire shape
70+
// and is intentionally not pinned here.
71+
func TestWireShape_Frozen(t *testing.T) {
72+
t.Parallel()
73+
74+
// Generic IR types are instantiated at concrete string/string/any params; the
75+
// type params do not change a field's name or json tag, only the element type.
76+
type S = string
77+
type E = string
78+
type C = any
79+
80+
cases := []struct {
81+
name string
82+
zero any
83+
want []fieldShape
84+
}{
85+
{
86+
name: "IR",
87+
zero: state.IR[S, E, C]{},
88+
want: []fieldShape{
89+
{"SchemaVersion", "schemaVersion,omitempty"},
90+
{"ID", "id,omitempty"},
91+
{"Name", "name"},
92+
{"Version", "version,omitempty"},
93+
{"Input", "input,omitempty"},
94+
{"Output", "output,omitempty"},
95+
{"Context", "context,omitempty"},
96+
{"States", "states,omitempty"},
97+
{"Initial", "initial"},
98+
{"HasInitial", "hasInitial"},
99+
{"Meta", "meta,omitempty"},
100+
},
101+
},
102+
{
103+
name: "State",
104+
zero: state.State[S, E, C]{},
105+
want: []fieldShape{
106+
{"Name", "name"},
107+
{"OwnedBy", "ownedBy,omitempty"},
108+
{"Transitions", "transitions,omitempty"},
109+
{"OnEntry", "onEntry,omitempty"},
110+
{"OnExit", "onExit,omitempty"},
111+
{"IsFinal", "isFinal,omitempty"},
112+
{"OnDone", "onDone,omitempty"},
113+
{"OnEntryAssign", "onEntryAssign,omitempty"},
114+
{"OnExitAssign", "onExitAssign,omitempty"},
115+
{"Children", "children,omitempty"},
116+
{"InitialChild", "initialChild,omitempty"},
117+
{"Regions", "regions,omitempty"},
118+
{"HistoryType", "historyType,omitempty"},
119+
{"HistoryDefault", "historyDefault,omitempty"},
120+
{"Invoke", "invoke,omitempty"},
121+
{"Parent", "-"},
122+
{"Meta", "meta,omitempty"},
123+
},
124+
},
125+
{
126+
name: "Transition",
127+
zero: state.Transition[S, E, C]{},
128+
want: []fieldShape{
129+
{"From", "from"},
130+
{"To", "to"},
131+
{"On", "on"},
132+
{"Guards", "guards,omitempty"},
133+
{"Effects", "effects,omitempty"},
134+
{"WaitMode", "waitMode,omitempty"},
135+
{"Assigns", "assigns,omitempty"},
136+
{"GuardExpr", "guardExpr,omitempty"},
137+
{"Internal", "internal,omitempty"},
138+
{"EventLess", "eventLess,omitempty"},
139+
{"After", "after,omitempty"},
140+
{"Wildcard", "wildcard,omitempty"},
141+
{"Forbidden", "forbidden,omitempty"},
142+
{"Reenter", "reenter,omitempty"},
143+
{"Raise", "raise,omitempty"},
144+
{"SrcFile", "srcFile,omitempty"},
145+
{"SrcLine", "srcLine,omitempty"},
146+
{"Meta", "meta,omitempty"},
147+
},
148+
},
149+
{
150+
name: "Region",
151+
zero: state.Region[S, E, C]{},
152+
want: []fieldShape{
153+
{"Name", "name"},
154+
{"States", "states,omitempty"},
155+
{"InitialChild", "initialChild,omitempty"},
156+
{"Meta", "meta,omitempty"},
157+
},
158+
},
159+
{
160+
name: "Invocation",
161+
zero: state.Invocation[S, E, C]{},
162+
want: []fieldShape{
163+
{"ID", "id,omitempty"},
164+
{"Src", "src"},
165+
{"Input", "input,omitempty"},
166+
{"OnDone", "onDone"},
167+
{"OnError", "onError"},
168+
{"Kind", "kind,omitempty"},
169+
{"SystemID", "systemId,omitempty"},
170+
{"Meta", "meta,omitempty"},
171+
},
172+
},
173+
{
174+
name: "Ref",
175+
zero: state.Ref{},
176+
want: []fieldShape{
177+
{"Name", "name"},
178+
{"Params", "params,omitempty"},
179+
{"Meta", "meta,omitempty"},
180+
},
181+
},
182+
{
183+
name: "ContextSchema",
184+
zero: state.ContextSchema{},
185+
want: []fieldShape{
186+
{"Fields", "fields,omitempty"},
187+
{"Meta", "meta,omitempty"},
188+
},
189+
},
190+
{
191+
name: "Descriptor",
192+
zero: state.Descriptor{},
193+
want: []fieldShape{
194+
{"Kind", "kind"},
195+
{"Name", "name"},
196+
{"Description", "description,omitempty"},
197+
{"Category", "category,omitempty"},
198+
{"Examples", "examples,omitempty"},
199+
{"Params", "params,omitempty"},
200+
{"Reads", "reads,omitempty"},
201+
{"Writes", "writes,omitempty"},
202+
{"Binding", "binding,omitempty"},
203+
},
204+
},
205+
{
206+
name: "ParamSpec",
207+
zero: state.ParamSpec{},
208+
want: []fieldShape{
209+
{"Name", "name"},
210+
{"Type", "type"},
211+
{"Required", "required,omitempty"},
212+
{"Description", "description,omitempty"},
213+
{"Default", "default,omitempty"},
214+
{"Enum", "enum,omitempty"},
215+
{"Examples", "examples,omitempty"},
216+
},
217+
},
218+
{
219+
name: "BindingSpec",
220+
zero: state.BindingSpec{},
221+
want: []fieldShape{
222+
{"Transport", "transport,omitempty"},
223+
{"Meta", "meta,omitempty"},
224+
},
225+
},
226+
}
227+
228+
for _, tc := range cases {
229+
tc := tc
230+
t.Run(tc.name, func(t *testing.T) {
231+
t.Parallel()
232+
assertShape(t, tc.name, reflectShape(t, tc.zero), tc.want)
233+
})
234+
}
235+
}
236+
10237
// TestEnumWireValues_Frozen pins the numeric wire value of every closed-int enum
11238
// that serializes as a bare integer (WaitMode, HistoryType, ActorKind). These
12239
// integers are part of the frozen v1.0 wire contract: a recorded document encodes

0 commit comments

Comments
 (0)