fluid-core/tools/schemagen/main.go
tegwick 76912adef8
Some checks failed
ci / build (push) Failing after 1m6s
Generate contract types, pin fixtures to spec, add build and ADRs
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
2026-09-04 02:03:12 +02:00

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)
}