Generate contract types, pin fixtures to spec, add build and ADRs
Some checks failed
ci / build (push) Failing after 1m6s
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
This commit is contained in:
parent
fbbf56df7a
commit
76912adef8
44 changed files with 4010 additions and 7 deletions
495
tools/schemagen/main.go
Normal file
495
tools/schemagen/main.go
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
// Command schemagen generates Go types from the FLUID wire-contract schemas.
|
||||
//
|
||||
// The record types are never hand-written: schemas/ is the source of truth, and
|
||||
// drift between the specification and the implementation must fail the build
|
||||
// rather than survive as a quietly diverging struct.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// schema is the subset of JSON Schema the FLUID contract actually uses.
|
||||
type schema struct {
|
||||
ID string `json:"$id"`
|
||||
Ref string `json:"$ref"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Type any `json:"type"`
|
||||
Const any `json:"const"`
|
||||
Enum []string `json:"enum"`
|
||||
Format string `json:"format"`
|
||||
Properties map[string]*schema `json:"properties"`
|
||||
Required []string `json:"required"`
|
||||
Items *schema `json:"items"`
|
||||
Defs map[string]*schema `json:"$defs"`
|
||||
OneOf []*schema `json:"oneOf"`
|
||||
AdditionalProperties json.RawMessage `json:"additionalProperties"`
|
||||
PropertyNames *schema `json:"propertyNames"`
|
||||
}
|
||||
|
||||
// additional reports how additionalProperties was written: as a schema
|
||||
// (a map value type), as false, or absent.
|
||||
func (s *schema) additional() (*schema, bool) {
|
||||
if len(s.AdditionalProperties) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var b bool
|
||||
if err := json.Unmarshal(s.AdditionalProperties, &b); err == nil {
|
||||
return nil, b // true means "anything"; false means closed
|
||||
}
|
||||
var sub schema
|
||||
if err := json.Unmarshal(s.AdditionalProperties, &sub); err == nil {
|
||||
return &sub, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *schema) typeIs(want string) bool {
|
||||
switch t := s.Type.(type) {
|
||||
case string:
|
||||
return t == want
|
||||
case []any:
|
||||
for _, v := range t {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *schema) nullable() bool {
|
||||
if s.typeIs("null") {
|
||||
return true
|
||||
}
|
||||
for _, o := range s.OneOf {
|
||||
if o.typeIs("null") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var initialisms = map[string]string{
|
||||
"id": "ID", "ids": "IDs", "url": "URL", "uri": "URI", "api": "API",
|
||||
"ms": "MS", "http": "HTTP", "json": "JSON", "yaml": "YAML", "sha": "SHA",
|
||||
"p95": "P95", "iei": "IEI", "sla": "SLA", "ai": "AI",
|
||||
}
|
||||
|
||||
// splitCamel inserts a separator at lowercase-to-uppercase boundaries so that
|
||||
// camelCase schema keys such as "cohortId" split into words the initialism map
|
||||
// can then correct.
|
||||
func splitCamel(s string) string {
|
||||
var b strings.Builder
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
if i > 0 && r >= 'A' && r <= 'Z' && runes[i-1] >= 'a' && runes[i-1] <= 'z' {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func goName(s string) string {
|
||||
s = splitCamel(s)
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '_' || r == '-' || r == '.' })
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
if up, ok := initialisms[strings.ToLower(p)]; ok {
|
||||
b.WriteString(up)
|
||||
continue
|
||||
}
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(strings.ToUpper(p[:1]))
|
||||
b.WriteString(p[1:])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// generator accumulates named types while walking the schemas.
|
||||
type generator struct {
|
||||
commonDefs map[string]string // $defs key -> Go type name
|
||||
decls []string
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func (g *generator) emit(decl string) {
|
||||
g.decls = append(g.decls, decl)
|
||||
}
|
||||
|
||||
func comment(indent, text string) string {
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
var out []string
|
||||
line := indent + "//"
|
||||
for _, w := range strings.Fields(text) {
|
||||
if len(line)+1+len(w) > 84 && line != indent+"//" {
|
||||
out = append(out, line)
|
||||
line = indent + "//"
|
||||
}
|
||||
line += " " + w
|
||||
}
|
||||
out = append(out, line)
|
||||
return strings.Join(out, "\n") + "\n"
|
||||
}
|
||||
|
||||
// resolveRef maps "common.schema.json#/$defs/foo" to its generated Go type.
|
||||
func (g *generator) resolveRef(ref string) (string, bool) {
|
||||
const marker = "#/$defs/"
|
||||
i := strings.Index(ref, marker)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
key := ref[i+len(marker):]
|
||||
name, ok := g.commonDefs[key]
|
||||
return name, ok
|
||||
}
|
||||
|
||||
// goType returns the Go type for s, generating nested named types as needed.
|
||||
// parent seeds the name of any anonymous struct or enum encountered.
|
||||
func (g *generator) goType(parent, field string, s *schema, required bool) string {
|
||||
if s.Ref != "" {
|
||||
if name, ok := g.resolveRef(s.Ref); ok {
|
||||
if !required {
|
||||
return "*" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
return "any"
|
||||
}
|
||||
|
||||
nullable := s.nullable()
|
||||
|
||||
// oneOf that is just "X or null" collapses to a pointer to X.
|
||||
if len(s.OneOf) > 0 {
|
||||
for _, o := range s.OneOf {
|
||||
if o.typeIs("null") {
|
||||
continue
|
||||
}
|
||||
inner := g.goType(parent, field, o, true)
|
||||
return "*" + strings.TrimPrefix(inner, "*")
|
||||
}
|
||||
return "any"
|
||||
}
|
||||
|
||||
// A union of several concrete types (guardrail thresholds may be number,
|
||||
// boolean or string) has no faithful Go equivalent but `any`.
|
||||
if ts, ok := s.Type.([]any); ok {
|
||||
concrete := 0
|
||||
for _, t := range ts {
|
||||
if t != "null" {
|
||||
concrete++
|
||||
}
|
||||
}
|
||||
if concrete > 1 {
|
||||
return "any"
|
||||
}
|
||||
}
|
||||
|
||||
name := parent + goName(field)
|
||||
|
||||
switch {
|
||||
case len(s.Enum) > 0:
|
||||
g.emitEnum(name, s)
|
||||
if !required || nullable {
|
||||
return "*" + name
|
||||
}
|
||||
return name
|
||||
|
||||
case s.typeIs("object"):
|
||||
if len(s.Properties) > 0 {
|
||||
g.emitStruct(name, s)
|
||||
if !required || nullable {
|
||||
return "*" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
if sub, ok := s.additional(); ok && sub != nil {
|
||||
return "map[string]" + g.goType(name, "Value", sub, true)
|
||||
}
|
||||
return "map[string]any"
|
||||
|
||||
case s.typeIs("array"):
|
||||
if s.Items == nil {
|
||||
return "[]any"
|
||||
}
|
||||
return "[]" + strings.TrimPrefix(g.goType(name, "Item", s.Items, true), "*")
|
||||
|
||||
case s.typeIs("string"):
|
||||
base := "string"
|
||||
if s.Format == "date-time" {
|
||||
base = "time.Time"
|
||||
}
|
||||
if nullable || (!required && base == "time.Time") {
|
||||
return "*" + base
|
||||
}
|
||||
return base
|
||||
|
||||
case s.typeIs("integer"):
|
||||
if nullable || !required {
|
||||
return "*int64"
|
||||
}
|
||||
return "int64"
|
||||
|
||||
case s.typeIs("number"):
|
||||
if nullable || !required {
|
||||
return "*float64"
|
||||
}
|
||||
return "float64"
|
||||
|
||||
case s.typeIs("boolean"):
|
||||
if nullable || !required {
|
||||
return "*bool"
|
||||
}
|
||||
return "bool"
|
||||
}
|
||||
|
||||
return "any"
|
||||
}
|
||||
|
||||
// symbolNames covers enum values that are operators rather than words, so the
|
||||
// generated constants stay valid Go identifiers.
|
||||
var symbolNames = map[string]string{
|
||||
"<": "Lt", "<=": "Lte", "==": "Eq", "!=": "Neq", ">=": "Gte", ">": "Gt",
|
||||
}
|
||||
|
||||
// enumConstName builds an identifier suffix for one enum value.
|
||||
func enumConstName(v string, index int) string {
|
||||
if sym, ok := symbolNames[v]; ok {
|
||||
return sym
|
||||
}
|
||||
name := goName(v)
|
||||
cleaned := make([]rune, 0, len(name))
|
||||
for _, r := range name {
|
||||
if r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
||||
cleaned = append(cleaned, r)
|
||||
}
|
||||
}
|
||||
name = string(cleaned)
|
||||
if name == "" || name[0] >= '0' && name[0] <= '9' {
|
||||
name = fmt.Sprintf("Value%d", index)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (g *generator) emitEnum(name string, s *schema) {
|
||||
if g.seen[name] {
|
||||
return
|
||||
}
|
||||
g.seen[name] = true
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(comment("", s.Description))
|
||||
fmt.Fprintf(&b, "type %s string\n\nconst (\n", name)
|
||||
for i, v := range s.Enum {
|
||||
fmt.Fprintf(&b, "\t%s%s %s = %q\n", name, enumConstName(v, i), name, v)
|
||||
}
|
||||
b.WriteString(")\n\n")
|
||||
|
||||
// A generated Valid method keeps enum checking in one place.
|
||||
fmt.Fprintf(&b, "// Valid reports whether v is a defined %s.\n", name)
|
||||
fmt.Fprintf(&b, "func (v %s) Valid() bool {\n\tswitch v {\n\tcase ", name)
|
||||
quoted := make([]string, 0, len(s.Enum))
|
||||
for i, v := range s.Enum {
|
||||
quoted = append(quoted, name+enumConstName(v, i))
|
||||
}
|
||||
b.WriteString(strings.Join(quoted, ", "))
|
||||
b.WriteString(":\n\t\treturn true\n\t}\n\treturn false\n}\n")
|
||||
|
||||
g.emit(b.String())
|
||||
}
|
||||
|
||||
func (g *generator) emitStruct(name string, s *schema) {
|
||||
if g.seen[name] {
|
||||
return
|
||||
}
|
||||
g.seen[name] = true
|
||||
|
||||
req := map[string]bool{}
|
||||
for _, r := range s.Required {
|
||||
req[r] = true
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(s.Properties))
|
||||
for k := range s.Properties {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// Fields are resolved before the struct is written so nested types are
|
||||
// declared in dependency order.
|
||||
type field struct{ name, typ, tag, doc string }
|
||||
fields := make([]field, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
p := s.Properties[k]
|
||||
typ := g.goType(name, k, p, req[k])
|
||||
tag := k
|
||||
if !req[k] {
|
||||
tag += ",omitempty"
|
||||
}
|
||||
fields = append(fields, field{goName(k), typ, tag, p.Description})
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(comment("", s.Description))
|
||||
fmt.Fprintf(&b, "type %s struct {\n", name)
|
||||
for i, f := range fields {
|
||||
if f.doc != "" {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(comment("\t", f.doc))
|
||||
}
|
||||
fmt.Fprintf(&b, "\t%s %s `json:%q yaml:%q`\n", f.name, f.typ, f.tag, f.tag)
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
|
||||
g.emit(b.String())
|
||||
}
|
||||
|
||||
func load(path string) (*schema, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s schema
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func write(path, pkg string, decls []string) error {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("// Code generated by tools/schemagen. DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: schemas/. Regenerate with `make generate`.\n\n")
|
||||
fmt.Fprintf(&b, "package %s\n\n", pkg)
|
||||
body := strings.Join(decls, "\n")
|
||||
if strings.Contains(body, "time.Time") {
|
||||
b.WriteString("import \"time\"\n\n")
|
||||
}
|
||||
b.WriteString(body)
|
||||
|
||||
src, err := format.Source(b.Bytes())
|
||||
if err != nil {
|
||||
// Write the unformatted source so the error is diagnosable.
|
||||
_ = os.WriteFile(path+".broken", b.Bytes(), 0o644)
|
||||
return fmt.Errorf("format %s: %w", path, err)
|
||||
}
|
||||
return os.WriteFile(path, src, 0o644)
|
||||
}
|
||||
|
||||
func main() {
|
||||
schemaDir := "schemas"
|
||||
outDir := filepath.Join("internal", "contract")
|
||||
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
common, err := load(filepath.Join(schemaDir, "common.schema.json"))
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
g := &generator{commonDefs: map[string]string{}, seen: map[string]bool{}}
|
||||
|
||||
// Pass one: name every common definition so record schemas can reference them.
|
||||
defKeys := make([]string, 0, len(common.Defs))
|
||||
for k := range common.Defs {
|
||||
defKeys = append(defKeys, k)
|
||||
}
|
||||
sort.Strings(defKeys)
|
||||
for _, k := range defKeys {
|
||||
g.commonDefs[k] = goName(k)
|
||||
}
|
||||
|
||||
// Pass two: declare them.
|
||||
for _, k := range defKeys {
|
||||
d := common.Defs[k]
|
||||
name := g.commonDefs[k]
|
||||
switch {
|
||||
case len(d.Enum) > 0:
|
||||
g.emitEnum(name, d)
|
||||
case d.typeIs("object") && len(d.Properties) > 0:
|
||||
g.emitStruct(name, d)
|
||||
default:
|
||||
if g.seen[name] {
|
||||
continue
|
||||
}
|
||||
g.seen[name] = true
|
||||
underlying := g.goType("", k, d, true)
|
||||
// Scalar definitions become distinct types rather than aliases:
|
||||
// passing a hypothesis id where a revision id belongs should not
|
||||
// compile. Everything else stays an alias to keep call sites plain.
|
||||
form := "type %s = %s\n"
|
||||
switch underlying {
|
||||
case "string", "float64", "int64":
|
||||
form = "type %s %s\n"
|
||||
}
|
||||
g.emit(comment("", d.Description) + fmt.Sprintf(form, name, underlying))
|
||||
}
|
||||
}
|
||||
|
||||
if err := write(filepath.Join(outDir, "common_gen.go"), "contract", g.decls); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("generated %s (%d declarations)\n", filepath.Join(outDir, "common_gen.go"), len(g.decls))
|
||||
|
||||
records := []string{
|
||||
"pressure", "hypothesis", "revision", "experiment", "event",
|
||||
"feedback", "backend-requirement", "revision-descriptor",
|
||||
"routing-policy", "telemetry-envelope",
|
||||
}
|
||||
|
||||
for _, rec := range records {
|
||||
s, err := load(filepath.Join(schemaDir, rec+".schema.json"))
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
rg := &generator{commonDefs: g.commonDefs, seen: map[string]bool{}}
|
||||
|
||||
// Each record schema is a single-property wrapper; generate the
|
||||
// wrapper plus the payload type it names.
|
||||
for key, prop := range s.Properties {
|
||||
rg.emitStruct(goName(key), prop)
|
||||
}
|
||||
var keys []string
|
||||
for k := range s.Properties {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var doc strings.Builder
|
||||
doc.WriteString(comment("", s.Description))
|
||||
fmt.Fprintf(&doc, "type %sDocument struct {\n", goName(rec))
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&doc, "\t%s %s `json:%q yaml:%q`\n", goName(k), goName(k), k, k)
|
||||
}
|
||||
doc.WriteString("}\n")
|
||||
rg.emit(doc.String())
|
||||
|
||||
out := filepath.Join(outDir, strings.ReplaceAll(rec, "-", "_")+"_gen.go")
|
||||
if err := write(out, "contract", rg.decls); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("generated %s\n", out)
|
||||
}
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "schemagen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue