Some checks failed
ci / build (push) Failing after 1m6s
Completes FLUID-WP-0002. The record types in internal/contract are generated from schemas/ by a dependency-free generator; fixtures transcribed from the spec's worked examples validate against those schemas and round-trip through the generated types with DisallowUnknownFields, so a spec change that misses the schemas fails CI rather than drifting silently. Adds identifier prefix helpers, Makefile, GitHub Actions, and five ADRs recording the Go choice, out-of-process attachment, the wire contract as boundary, the evidence store, and revision identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
203 lines
5.6 KiB
Go
203 lines
5.6 KiB
Go
package contract
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
// fixtureDir holds JSON copies of the spec-derived YAML fixtures, written by
|
|
// conformance/validate_schemas.py.
|
|
const fixtureDir = "../../conformance/fixtures/json"
|
|
|
|
// roundTrip decodes a fixture into T, re-encodes it, and reports any field the
|
|
// generated types dropped on the way through.
|
|
//
|
|
// This is the drift check that matters: a schema change that schemagen did not
|
|
// pick up shows here as a field that vanishes.
|
|
func roundTrip[T any](t *testing.T, name string) {
|
|
t.Helper()
|
|
|
|
raw, err := os.ReadFile(filepath.Join(fixtureDir, name+".json"))
|
|
if err != nil {
|
|
t.Fatalf("read fixture: %v (run `make validate` first)", err)
|
|
}
|
|
|
|
var typed T
|
|
dec := json.NewDecoder(bytesReader(raw))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&typed); err != nil {
|
|
t.Fatalf("decode into %T: %v", typed, err)
|
|
}
|
|
|
|
encoded, err := json.Marshal(typed)
|
|
if err != nil {
|
|
t.Fatalf("re-encode: %v", err)
|
|
}
|
|
|
|
var before, after map[string]any
|
|
if err := json.Unmarshal(raw, &before); err != nil {
|
|
t.Fatalf("unmarshal fixture: %v", err)
|
|
}
|
|
if err := json.Unmarshal(encoded, &after); err != nil {
|
|
t.Fatalf("unmarshal re-encoded: %v", err)
|
|
}
|
|
|
|
if missing := missingKeys(before, after, ""); len(missing) > 0 {
|
|
t.Errorf("round trip dropped fields: %v", missing)
|
|
}
|
|
}
|
|
|
|
// missingKeys walks want and reports paths absent from got.
|
|
//
|
|
// Explicit nulls and empty collections are skipped. The schemas treat
|
|
// "end: null" and an absent "end" as the same statement (the window is still
|
|
// open), so omitempty dropping them on re-encode is correct rather than drift.
|
|
// Genuine drift -- a fixture field the generated types have no home for -- is
|
|
// caught by DisallowUnknownFields on the way in.
|
|
func missingKeys(want, got map[string]any, prefix string) []string {
|
|
var missing []string
|
|
for k, wv := range want {
|
|
path := k
|
|
if prefix != "" {
|
|
path = prefix + "." + k
|
|
}
|
|
if isEmptyValue(wv) {
|
|
continue
|
|
}
|
|
gv, ok := got[k]
|
|
if !ok {
|
|
missing = append(missing, path)
|
|
continue
|
|
}
|
|
wm, wok := wv.(map[string]any)
|
|
gm, gok := gv.(map[string]any)
|
|
if wok && gok {
|
|
missing = append(missing, missingKeys(wm, gm, path)...)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// isEmptyValue reports values the schema treats as equivalent to absent.
|
|
func isEmptyValue(v any) bool {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return true
|
|
case []any:
|
|
return len(t) == 0
|
|
case map[string]any:
|
|
return len(t) == 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestFixturesRoundTrip(t *testing.T) {
|
|
t.Run("pressure", func(t *testing.T) { roundTrip[PressureDocument](t, "pressure") })
|
|
t.Run("hypothesis", func(t *testing.T) { roundTrip[HypothesisDocument](t, "hypothesis") })
|
|
t.Run("revision", func(t *testing.T) { roundTrip[RevisionDocument](t, "revision") })
|
|
t.Run("experiment", func(t *testing.T) { roundTrip[ExperimentDocument](t, "experiment") })
|
|
t.Run("event", func(t *testing.T) { roundTrip[EventDocument](t, "event") })
|
|
t.Run("feedback", func(t *testing.T) { roundTrip[FeedbackDocument](t, "feedback") })
|
|
t.Run("backend-requirement", func(t *testing.T) {
|
|
roundTrip[BackendRequirementDocument](t, "backend-requirement")
|
|
})
|
|
t.Run("revision-descriptor", func(t *testing.T) {
|
|
roundTrip[RevisionDescriptorDocument](t, "revision-descriptor")
|
|
})
|
|
t.Run("routing-policy", func(t *testing.T) { roundTrip[RoutingPolicyDocument](t, "routing-policy") })
|
|
t.Run("telemetry-envelope", func(t *testing.T) {
|
|
roundTrip[TelemetryEnvelopeDocument](t, "telemetry-envelope")
|
|
})
|
|
}
|
|
|
|
func TestKindOf(t *testing.T) {
|
|
cases := map[string]EntityKind{
|
|
"H-000184": KindHypothesis,
|
|
"R-000221": KindRevision,
|
|
"E-000093": KindExperiment,
|
|
"P-1831": KindPressure,
|
|
"BR-0041": KindBackendRequirement,
|
|
"D-2811": KindDecision,
|
|
"EV-990281": KindEvent,
|
|
"F-9821": KindFeedback,
|
|
"C-17": KindCohort,
|
|
}
|
|
for id, want := range cases {
|
|
got, ok := KindOf(id)
|
|
if !ok || got != want {
|
|
t.Errorf("KindOf(%q) = %q, %v; want %q", id, got, ok, want)
|
|
}
|
|
}
|
|
|
|
// BR- and EV- must not be swallowed by the single-letter prefixes.
|
|
if k, _ := KindOf("BR-1"); k != KindBackendRequirement {
|
|
t.Errorf("BR- prefix mis-classified as %q", k)
|
|
}
|
|
if k, _ := KindOf("EV-1"); k != KindEvent {
|
|
t.Errorf("EV- prefix mis-classified as %q", k)
|
|
}
|
|
|
|
if _, ok := KindOf("something"); ok {
|
|
t.Error("unprefixed identifier should not classify")
|
|
}
|
|
}
|
|
|
|
func TestRequireKind(t *testing.T) {
|
|
if err := RequireKind("R-1", KindRevision); err != nil {
|
|
t.Errorf("valid revision id rejected: %v", err)
|
|
}
|
|
err := RequireKind("H-1", KindRevision)
|
|
if err == nil {
|
|
t.Fatal("hypothesis id accepted where a revision id was required")
|
|
}
|
|
var wrong *ErrWrongKind
|
|
if !errorsAs(err, &wrong) {
|
|
t.Fatalf("expected *ErrWrongKind, got %T", err)
|
|
}
|
|
if wrong.Got != KindHypothesis || wrong.Want != KindRevision {
|
|
t.Errorf("unexpected error detail: %+v", wrong)
|
|
}
|
|
}
|
|
|
|
// Small helpers keep the test file free of imports the generated code does not
|
|
// already require.
|
|
func bytesReader(b []byte) *jsonReader { return &jsonReader{b: b} }
|
|
|
|
type jsonReader struct {
|
|
b []byte
|
|
i int
|
|
}
|
|
|
|
func (r *jsonReader) Read(p []byte) (int, error) {
|
|
if r.i >= len(r.b) {
|
|
return 0, errEOF
|
|
}
|
|
n := copy(p, r.b[r.i:])
|
|
r.i += n
|
|
return n, nil
|
|
}
|
|
|
|
var errEOF = errorString("EOF")
|
|
|
|
type errorString string
|
|
|
|
func (e errorString) Error() string { return string(e) }
|
|
|
|
func errorsAs(err error, target any) bool {
|
|
tv := reflect.ValueOf(target).Elem()
|
|
for err != nil {
|
|
if reflect.TypeOf(err).AssignableTo(tv.Type()) {
|
|
tv.Set(reflect.ValueOf(err))
|
|
return true
|
|
}
|
|
u, ok := err.(interface{ Unwrap() error })
|
|
if !ok {
|
|
return false
|
|
}
|
|
err = u.Unwrap()
|
|
}
|
|
return false
|
|
}
|