seatgen: a seat's states give a constant, key builders and parsers and strict value types, Go and TypeScript (ADR 0634 §6; issue 1966)

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-05 12:50:59 +01:00
parent 236ce2fe12
commit 0beef7ff9c
6 changed files with 408 additions and 14 deletions
@@ -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 {
@@ -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 (`<asker>`).
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, `<asker>.<id>` → 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:<name>/<key>` 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
}
@@ -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)
}
}
}
@@ -0,0 +1,3 @@
[{"name":"an ask","of":"state:asks/<asker>.<id>","given":{"state":"open","since":3}},
{"name":"a finished ask","of":"state:asks/<asker>.<id>.done","given":{"outcome":"refused"}},
{"name":"the defaults","of":"state:asks/defaults","given":{"any":"thing"}}]
@@ -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":"<asker>.<id>","value":{"type":"object","properties":{"state":{"type":"string"},"since":{"type":"integer"}},"required":["state"]}},
{"key":"<asker>.<id>.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.<id-of-ask>","value":{"type":"string"}}]}]}
@@ -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)
}