From 0beef7ff9ca211b62bd837a36af088f34434cb8e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 12:50:59 +0100 Subject: [PATCH] =?UTF-8?q?seatgen:=20a=20seat's=20states=20give=20a=20con?= =?UTF-8?q?stant,=20key=20builders=20and=20parsers=20and=20strict=20value?= =?UTF-8?q?=20types,=20Go=20and=20TypeScript=20(ADR=200634=20=C2=A76;=20is?= =?UTF-8?q?sue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../mesh-build-go/buildstep/seatgen/gogen.go | 33 ++- .../mesh-build-go/buildstep/seatgen/states.go | 256 ++++++++++++++++++ .../buildstep/seatgen/states_test.go | 115 ++++++++ .../seats/a-registry/fixtures/states.json | 3 + .../contracts/seats/a-registry/seat.json | 10 +- .../mesh-build-go/buildstep/seatgen/tsgen.go | 5 + 6 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 modules/mesh-build-go/buildstep/seatgen/states.go create mode 100644 modules/mesh-build-go/buildstep/seatgen/states_test.go create mode 100644 modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json diff --git a/modules/mesh-build-go/buildstep/seatgen/gogen.go b/modules/mesh-build-go/buildstep/seatgen/gogen.go index f7715b644..c7a5503d1 100644 --- a/modules/mesh-build-go/buildstep/seatgen/gogen.go +++ b/modules/mesh-build-go/buildstep/seatgen/gogen.go @@ -355,9 +355,15 @@ func declareAll(d seatdef.Definition, fromPart map[string]string) (*typer, error return nil, err } } - for _, r := range d.Records { - if err := decl(ident(r.Name)+"Record", "is one value of the record "+r.Name+".", r.Value); err != nil { - return nil, err + for _, st := range d.States { + for _, kf := range st.KeyForms { + vn, err := stateValueName(st.Name, kf) + if err != nil { + return nil, fmt.Errorf("state %s: %w", st.Name, err) + } + if err := decl(vn, "is one value of the state "+st.Name+" under the key "+kf.Key+".", kf.Value); err != nil { + return nil, err + } } } for _, p := range d.Provisions { @@ -413,6 +419,9 @@ func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) { b.WriteString(header) fmt.Fprintf(&b, "// Package %s is the contract of the seat %q, defined whole (seats/%s/seat.json): %s\npackage %s\n\n", pkg, d.Seat, d.Seat, oneLine(d.Description), pkg) + if len(d.States) > 0 { + b.WriteString(goStatesImports(d.States)) + } fmt.Fprintf(&b, "// Seat is the seat's name.\nconst Seat = %q\n\n", d.Seat) writeNames(&b, "Verb", seatdef.VerbNames(d.Verbs)) writeNames(&b, "Accept", seatdef.MessageNames(d.Accepts)) @@ -455,8 +464,10 @@ func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) { fmt.Fprintf(&b, ".\nfunc HolderSubject%s(holder string) string { return %q + holder + %q }\n\n", ident(e.Name), before, after) } } - if len(d.Records) > 0 { - writeNames(&b, "Record", recordNames(d.Records)) + if len(d.States) > 0 { + if err := goStates(&b, d.States); err != nil { + return nil, nil, err + } } if err := goTypes(&b, tp, map[string]*typ{}); err != nil { return nil, nil, err @@ -469,14 +480,6 @@ func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) { return src, test, err } -func recordNames(rs []seatdef.Record) []string { - out := make([]string, len(rs)) - for i, r := range rs { - out[i] = r.Name - } - return out -} - // goTypes writes every declared type; also are the kinds of types declared elsewhere (a part's), by name. func goTypes(b *bytes.Buffer, tp *typer, also map[string]*typ) error { kinds := map[string]*typ{} @@ -661,6 +664,10 @@ func fixtureTarget(d seatdef.Definition, kind, name string) (string, string, err return ident(name) + suffix, p + "$", nil } } + case "state": + if n, ok := stateFormFor(d, name); ok { + return n, "", nil + } case "holder-event": for _, e := range d.HolderEvents { if e.Name == name { diff --git a/modules/mesh-build-go/buildstep/seatgen/states.go b/modules/mesh-build-go/buildstep/seatgen/states.go new file mode 100644 index 000000000..1f88d1d07 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/states.go @@ -0,0 +1,256 @@ +package seatgen + +import ( + "bytes" + "fmt" + "go/token" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// A keyToken is one piece of a key form's pattern: a literal, or a named part (``). +type keyToken struct { + part bool + text string +} + +// keyTokens reads a key form's pattern into its pieces. Two parts with no literal between them are refused: a reader +// could not tell where the first ends. +func keyTokens(key string) ([]keyToken, error) { + var out []keyToken + rest := key + for rest != "" { + i := strings.Index(rest, "<") + if i < 0 { + out = append(out, keyToken{text: rest}) + break + } + if i > 0 { + out = append(out, keyToken{text: rest[:i]}) + } + j := strings.Index(rest[i:], ">") + if j < 0 { + return nil, fmt.Errorf("key %q: a part is not closed", key) + } + out = append(out, keyToken{part: true, text: rest[i+1 : i+j]}) + rest = rest[i+j+1:] + } + for i := 1; i < len(out); i++ { + if out[i].part && out[i-1].part { + return nil, fmt.Errorf("key %q: two parts with no literal between them", key) + } + } + return out, nil +} + +func keyParts(ts []keyToken) []string { + var out []string + for _, t := range ts { + if t.part { + out = append(out, t.text) + } + } + return out +} + +// formIdent is the name a key form's functions and value type are built from: the state's name, then "By" and the +// form's parts and literals in order (asks, `.` → AsksByAskerID). +func formIdent(state, key string, ts []keyToken) string { + var b strings.Builder + b.WriteString(ident(state) + "By") + for _, t := range ts { + b.WriteString(ident(t.text)) + } + return b.String() +} + +var reservedWords = map[string]bool{"default": true, "new": true, "delete": true, "class": true, "function": true, "var": true, "let": true, "const": true, "in": true, "of": true} + +// paramName is a part's name as a function parameter: local-name → localName. +func paramName(part string) string { + n := ident(part) + n = strings.ToLower(n[:1]) + n[1:] + if strings.HasPrefix(ident(part), "ID") { + n = "id" + ident(part)[2:] + } + if token.IsKeyword(n) || reservedWords[n] { + n += "Part" + } + return n +} + +// stateValueName is the Go and TypeScript type of one value of a state under a key form. +func stateValueName(state string, kf seatdef.KeyForm) (string, error) { + ts, err := keyTokens(kf.Key) + if err != nil { + return "", err + } + return formIdent(state, kf.Key, ts) + "Value", nil +} + +// stateFormFor is the state and key form a fixture's `state:/` names. +func stateFormFor(d seatdef.Definition, name string) (string, bool) { + state, key, _ := strings.Cut(name, "/") + for _, s := range d.States { + if s.Name != state { + continue + } + for _, kf := range s.KeyForms { + if kf.Key == key { + n, err := stateValueName(s.Name, kf) + return n, err == nil + } + } + } + return "", false +} + +// goStates writes a seat's states: a constant for each local name, and for each key form its builder, its parser and +// the strict reader of a value (a field the type does not know is refused). +func goStates(b *bytes.Buffer, states []seatdef.State) error { + names := make([]string, len(states)) + for i, s := range states { + names[i] = s.Name + } + writeNames(b, "State", names) + for _, s := range states { + for _, kf := range s.KeyForms { + ts, err := keyTokens(kf.Key) + if err != nil { + return fmt.Errorf("state %s: %w", s.Name, err) + } + fi := formIdent(s.Name, kf.Key, ts) + params := keyParts(ts) + var plist, expr []string + for _, t := range ts { + if t.part { + expr = append(expr, paramName(t.text)) + } else { + expr = append(expr, fmt.Sprintf("%q", t.text)) + } + } + for _, p := range params { + plist = append(plist, paramName(p)) + } + sig := "" + if len(plist) > 0 { + sig = strings.Join(plist, ", ") + " string" + } + fmt.Fprintf(b, "// %sKey is the key of one value of the state %s under the form %q.\nfunc %sKey(%s) string { return %s }\n\n", + fi, s.Name, kf.Key, fi, sig, strings.Join(expr, " + ")) + rets := make([]string, 0, len(plist)+1) + zero := make([]string, 0, len(plist)+1) + for range plist { + rets = append(rets, "string") + zero = append(zero, `""`) + } + rets = append(rets, "bool") + zero = append(zero, "false") + fmt.Fprintf(b, "// Parse%sKey reads the parts of a key of the form %q, in the order of the form; the last result is false for a key of another form.\nfunc Parse%sKey(key string) (%s) {\n", + fi, kf.Key, fi, strings.Join(rets, ", ")) + fail := "return " + strings.Join(zero, ", ") + b.WriteString("\trest := key\n") + var got []string + for i := 0; i < len(ts); { + t := ts[i] + switch { + case !t.part: + fmt.Fprintf(b, "\tif !strings.HasPrefix(rest, %q) {\n\t\t%s\n\t}\n\trest = rest[%d:]\n", t.text, fail, len(t.text)) + i++ + case i+1 < len(ts): + fmt.Fprintf(b, "\tv%d, after%d, found%d := strings.Cut(rest, %q)\n\tif !found%d || v%d == \"\" {\n\t\t%s\n\t}\n\trest = after%d\n", + i, i, i, ts[i+1].text, i, i, fail, i) + got = append(got, fmt.Sprintf("v%d", i)) + i += 2 + default: + fmt.Fprintf(b, "\tif rest == \"\" {\n\t\t%s\n\t}\n\tv%d := rest\n\trest = \"\"\n", fail, i) + got = append(got, fmt.Sprintf("v%d", i)) + i++ + } + } + b.WriteString("\tif rest != \"\" {\n\t\t" + fail + "\n\t}\n") + fmt.Fprintf(b, "\treturn %s\n}\n\n", strings.Join(append(got, "true"), ", ")) + vn := fi + "Value" + fmt.Fprintf(b, "// Decode%s reads one value of the state %s under the form %q, refusing a field %s does not know and any text after the value.\n"+ + "func Decode%s(raw []byte) (%s, error) {\n\tvar v %s\n\tdec := json.NewDecoder(bytes.NewReader(raw))\n\tdec.DisallowUnknownFields()\n"+ + "\tif err := dec.Decode(&v); err != nil {\n\t\treturn v, err\n\t}\n\tif dec.More() {\n\t\treturn v, errors.New(\"text after the value\")\n\t}\n\treturn v, nil\n}\n\n", + vn, s.Name, kf.Key, vn, vn, vn, vn) + } + } + return nil +} + +// goStatesImports is the import block a seat's states need: the strict reader always, the parser's string functions +// where a key form has a literal or a part that is not last. +func goStatesImports(states []seatdef.State) string { + needStrings := false + for _, s := range states { + for _, kf := range s.KeyForms { + ts, _ := keyTokens(kf.Key) + for i, t := range ts { + if !t.part || i+1 < len(ts) { + needStrings = true + } + } + } + } + out := "import (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n" + if needStrings { + out += "\t\"strings\"\n" + } + return out + ")\n\n" +} + +// tsStates writes a seat's states for TypeScript: the local names, and for each key form its builder and parser (the +// value's type is declared with the seat's other types). +func tsStates(b *bytes.Buffer, states []seatdef.State) error { + names := make([]string, len(states)) + for i, s := range states { + names[i] = s.Name + } + tsNames(b, "states", names) + for _, s := range states { + for _, kf := range s.KeyForms { + ts, err := keyTokens(kf.Key) + if err != nil { + return fmt.Errorf("state %s: %w", s.Name, err) + } + fn := tsName(formIdent(s.Name, kf.Key, ts)) + var args, expr, fields, got []string + for _, t := range ts { + if t.part { + p := paramName(t.text) + args = append(args, p+": string") + expr = append(expr, p) + fields = append(fields, p+": string") + got = append(got, p) + } else { + expr = append(expr, strconv.Quote(t.text)) + } + } + fmt.Fprintf(b, "/** The key of one value of the state %s under the form %s. */\nexport function %sKey(%s): string {\n return %s;\n}\n\n", + s.Name, strconv.Quote(kf.Key), fn, strings.Join(args, ", "), strings.Join(expr, " + ")) + fmt.Fprintf(b, "/** The parts of a key of the form %s, or undefined for a key of another form. */\nexport function parse%sKey(key: string): { %s } | undefined {\n let rest = key;\n", + strconv.Quote(kf.Key), ident(fn), strings.Join(fields, "; ")) + for i := 0; i < len(ts); { + t := ts[i] + switch { + case !t.part: + fmt.Fprintf(b, " if (!rest.startsWith(%s)) return undefined;\n rest = rest.slice(%d);\n", strconv.Quote(t.text), len(t.text)) + i++ + case i+1 < len(ts): + fmt.Fprintf(b, " const at%d = rest.indexOf(%s);\n if (at%d <= 0) return undefined;\n const %s = rest.slice(0, at%d);\n rest = rest.slice(at%d + %d);\n", + i, strconv.Quote(ts[i+1].text), i, paramName(t.text), i, i, len(ts[i+1].text)) + i += 2 + default: + fmt.Fprintf(b, " if (rest === \"\") return undefined;\n const %s = rest;\n rest = \"\";\n", paramName(t.text)) + i++ + } + } + fmt.Fprintf(b, " if (rest !== \"\") return undefined;\n return { %s };\n}\n\n", strings.Join(got, ", ")) + } + } + return nil +} diff --git a/modules/mesh-build-go/buildstep/seatgen/states_test.go b/modules/mesh-build-go/buildstep/seatgen/states_test.go new file mode 100644 index 000000000..cb6aed284 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/states_test.go @@ -0,0 +1,115 @@ +package seatgen + +import ( + "os" + "os/exec" + "path/filepath" + re "regexp" + "strings" + "testing" +) + +// TestAStateHasItsNamesKeyFormsAndStrictValues is ADR 0634 §6's generator check for `states`: a seat's states give a +// constant per local name, per key form a builder, a parser and a strict reader of the value, and the fixtures of +// `state:` decode into those types. The generated seat package is written out and run with `go vet` and `go test`, so +// what the generator writes is compiled, not only read. +func TestAStateHasItsNamesKeyFormsAndStrictValues(t *testing.T) { + m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts") + if err != nil { + t.Fatal(err) + } + files, err := Generate(m) + if err != nil { + t.Fatal(err) + } + src := string(files["go/seats/aregistry/contract.go"]) + for _, want := range []string{ + "func AsksByAskerIDKey(asker, id string) string", "func ParseAsksByAskerIDKey(key string) (string, string, bool)", + "func DecodeAsksByAskerIDValue(raw []byte)", "type AsksByAskerIDValue struct", + "func AsksByAskerIDDoneKey(", "func AsksByDefaultsKey() string", + "func LocalNameBySeenIDOfAskKey(idOfAsk string) string", + } { + if !strings.Contains(src, want) { + t.Errorf("the seat's package lacks %q:\n%s", want, src) + } + } + for _, c := range []string{`StateAsks\s+= "asks"`, `StateLocalName\s+= "local-name"`} { + if !re.MustCompile(c).MatchString(src) { + t.Errorf("the seat's package lacks a constant %s", c) + } + } + if strings.Contains(src, "Record") { + t.Errorf("the seat's package still names a record:\n%s", src) + } + ts := string(files["ts/seats/aregistry/src/index.ts"]) + for _, want := range []string{"export const states = {", "export function asksByAskerIDKey(asker: string, id: string): string", + "export function parseAsksByAskerIDKey(key: string)", "export interface AsksByAskerIDValue"} { + if !strings.Contains(ts, want) { + t.Errorf("the seat's npm package lacks %q:\n%s", want, ts) + } + } + + dir := t.TempDir() + for p, b := range files { + if !strings.HasPrefix(p, "go/seats/aregistry/") { + continue + } + dst := filepath.Join(dir, strings.TrimPrefix(p, "go/seats/aregistry/")) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, b, 0o644); err != nil { + t.Fatal(err) + } + } + extra := `package aregistry + +import "testing" + +func TestKeysAndValues(t *testing.T) { + if k := AsksByAskerIDKey("a", "7"); k != "a.7" { + t.Fatal(k) + } + if a, id, ok := ParseAsksByAskerIDKey("a.7"); !ok || a != "a" || id != "7" { + t.Fatal(a, id, ok) + } + for _, bad := range []string{"", "a", ".7", "a.", "a.7.done"} { + if bad == "a.7.done" { + continue // the id takes the rest: another form is told apart by its own parser + } + if _, _, ok := ParseAsksByAskerIDKey(bad); ok { + t.Errorf("%q parsed", bad) + } + } + if a, id, ok := ParseAsksByAskerIDDoneKey("a.7.done"); !ok || a != "a" || id != "7" { + t.Fatal(a, id, ok) + } + if _, _, ok := ParseAsksByAskerIDDoneKey("a.7"); ok { + t.Error("a key without its literal parsed") + } + if !ParseAsksByDefaultsKey("defaults") || ParseAsksByDefaultsKey("defaultsx") { + t.Error("a literal key") + } + if _, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open","extra":1}` + "`" + `)); err == nil { + t.Error("a field the type does not know was taken") + } + if _, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open"} {}` + "`" + `)); err == nil { + t.Error("text after the value was taken") + } + if v, err := DecodeAsksByAskerIDValue([]byte(` + "`" + `{"state":"open","since":3}` + "`" + `)); err != nil || v.State != "open" { + t.Error(v, err) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "extra_test.go"), []byte(extra), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"vet", "./..."}, {"test", "./..."}} { + cmd := exec.Command("go", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go %s on the generated package: %v\n%s", strings.Join(args, " "), err, out) + } + } +} diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json new file mode 100644 index 000000000..e68231540 --- /dev/null +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/fixtures/states.json @@ -0,0 +1,3 @@ +[{"name":"an ask","of":"state:asks/.","given":{"state":"open","since":3}}, + {"name":"a finished ask","of":"state:asks/..done","given":{"outcome":"refused"}}, + {"name":"the defaults","of":"state:asks/defaults","given":{"any":"thing"}}] diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json index 732353705..77642d3c9 100644 --- a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json +++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json @@ -1,3 +1,11 @@ {"format":1,"seat":"a-registry","scope":"mesh","grain":"whole","decision":"ADR 1","includes":["registry"], "verbs":[{"name":"hold","input":{"type":"object"},"answer":{"type":"object"}}], - "family":{"name":"a-registry.events","max-age-days":7}} + "family":{"name":"a-registry.events","max-age-days":7}, + "states":[ + {"name":"asks","template":"state","description":"the asks held, one key per asker and ask", + "key-forms":[ + {"key":".","value":{"type":"object","properties":{"state":{"type":"string"},"since":{"type":"integer"}},"required":["state"]}}, + {"key":"..done","value":{"type":"object","properties":{"outcome":{"type":"string"}},"required":["outcome"]}}, + {"key":"defaults","value":{"type":"object","description":"free"}}]}, + {"name":"local-name","template":"events","max-age-days":3, + "key-forms":[{"key":"seen.","value":{"type":"string"}}]}]} diff --git a/modules/mesh-build-go/buildstep/seatgen/tsgen.go b/modules/mesh-build-go/buildstep/seatgen/tsgen.go index c8fea5cbe..dc5e71fcf 100644 --- a/modules/mesh-build-go/buildstep/seatgen/tsgen.go +++ b/modules/mesh-build-go/buildstep/seatgen/tsgen.go @@ -131,6 +131,11 @@ func tsFiles(m *Module, out map[string][]byte) error { tsName(e.Name), before, after) } } + if len(d.States) > 0 { + if err := tsStates(&b, d.States); err != nil { + return err + } + } for _, n := range sortedKeys(fromPart) { fmt.Fprintf(&b, "export type %s = %s.%s;\n", n, tsName(fromPart[n]), n) }