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
495 lines
13 KiB
Go
495 lines
13 KiB
Go
// 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)
|
|
}
|