diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md
index 7ffcfc4e..d45552ac 100644
--- a/modules/mesh-build-go/README.md
+++ b/modules/mesh-build-go/README.md
@@ -170,6 +170,11 @@ and xz, read in Go as the build agent reads it, issue 1590); it runs no command
and mesh-build-image require it and replace it to the sibling directory, since it is the same repository. What the delivery
parts read of a step (the failure contract, the fetch request, the stale-step sweep, the step id) stays in mesh-sdk's
`buildstep`.
+- The seat generator (design 26 *Contracts modules*) is the package `buildstep/seatgen` and the command `buildstep/cmd/seatgen`,
+ one program writing a contracts module's Go and TypeScript (`seatgen
`; `seatgen -check ` names every path out of
+ step and runs the client and artifact checks). Its version is buildstep's, so the build input of a compare is the
+ mesh-build-go (or mesh-build-node) build itself: a new generator is a new build module and a new fingerprint. A contracts
+ repository runs it as `go run git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/cmd/seatgen@ -check .`.
- `build_go_imports` (a tool, and the last step of a build ask that built an artifact) answers the mesh packages the module's
Go programs import: `go list -deps` of each Go bundle's program, tests left out, the packages of the forge outside the
module's own directory (a sibling package of its own repository counts), sorted and each once. A build ask carries them as
diff --git a/modules/mesh-build-go/buildstep/cmd/seatgen/main.go b/modules/mesh-build-go/buildstep/cmd/seatgen/main.go
new file mode 100644
index 00000000..9cd41717
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/cmd/seatgen/main.go
@@ -0,0 +1,78 @@
+// Command seatgen writes, or with -check compares, a contracts module's generated Go and TypeScript.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen"
+)
+
+func main() {
+ check := flag.Bool("check", false, "compare instead of writing: name every path out of step")
+ goModule := flag.String("go-module", "git.novox.be/novox/mesh-contracts/go", "the generated Go tree's module path")
+ tsPackage := flag.String("ts-package", "@novox/mesh-contracts", "the generated TypeScript package's name")
+ flag.Parse()
+ dir := flag.Arg(0)
+ m, err := seatgen.Load(dir, *goModule, *tsPackage)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ files, err := seatgen.Generate(m)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if *check {
+ out, err := seatgen.Compare(dir, files)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ // The clients beside the contracts (ADR 0627 §3) and module.json's Go and npm package artifacts (ADR 0625 §1, §3).
+ for _, check := range []func(string, *seatgen.Module) ([]string, error){seatgen.CheckClients, seatgen.CheckArtifacts} {
+ more, err := check(dir, m)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ out = append(out, more...)
+ }
+ for _, p := range out {
+ fmt.Println(p)
+ }
+ if len(out) > 0 {
+ os.Exit(1)
+ }
+ return
+ }
+ for _, root := range seatgen.GeneratedRoots {
+ _ = os.RemoveAll(filepath.Join(dir, root, "src"))
+ }
+ // Each seat's npm package's sources too (ADR 0625 §1), so a file no definition writes any more is gone.
+ stale, _ := filepath.Glob(filepath.Join(dir, "ts", "seats", "*", "src"))
+ for _, p := range stale {
+ _ = os.RemoveAll(p)
+ }
+ paths := make([]string, 0, len(files))
+ for p := range files {
+ paths = append(paths, p)
+ }
+ sort.Strings(paths)
+ for _, p := range paths {
+ full := filepath.Join(dir, p)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if err := os.WriteFile(full, files[p], 0o644); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ }
+ fmt.Println(len(paths), "files")
+}
diff --git a/modules/mesh-build-go/buildstep/generated.go b/modules/mesh-build-go/buildstep/generated.go
index 4e5e265b..488dc1ec 100644
--- a/modules/mesh-build-go/buildstep/generated.go
+++ b/modules/mesh-build-go/buildstep/generated.go
@@ -2,7 +2,7 @@ package buildstep
// **Regenerate and compare** (CAT-BG5, CAT-BN2; novox/hq ADR 0608 §5, design 26 *Contracts modules*). A module whose
// package artifact names `generated-from` holds contracts only: every file under its generated roots is written by the
-// seat generator (seatgen) from the definitions in that directory. A build module regenerates them with the generator
+// seat generator (package seatgen beside this file, command cmd/seatgen) from the definitions in that directory. A build module regenerates them with the generator
// it was built with (its version is this package's requirement, so a new generator is a new build module and a new
// fingerprint of what it builds) and names each file out of step: one that differs, one missing, one under a generated
// root the generator did not write. The test step fails on any; a trunk build's ask answers them in its out-of-step
@@ -21,7 +21,7 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/seatgen/seatgen"
+ "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen"
)
// GeneratedRootGo and GeneratedRootTS are the generated roots of a contracts module, one per build module.
diff --git a/modules/mesh-build-go/buildstep/generated_test.go b/modules/mesh-build-go/buildstep/generated_test.go
index 54681643..369466e3 100644
--- a/modules/mesh-build-go/buildstep/generated_test.go
+++ b/modules/mesh-build-go/buildstep/generated_test.go
@@ -11,7 +11,7 @@ import (
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/seatgen/seatgen"
+ "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen"
)
// contracts writes a contracts module of one seat, its generated files as the generator writes them, and answers its
diff --git a/modules/mesh-build-go/buildstep/go.mod b/modules/mesh-build-go/buildstep/go.mod
index d414771d..d61305e9 100644
--- a/modules/mesh-build-go/buildstep/go.mod
+++ b/modules/mesh-build-go/buildstep/go.mod
@@ -4,7 +4,6 @@ go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
- git.novox.be/novox/seatgen v0.0.0
github.com/nats-io/nats.go v1.54.0
)
diff --git a/modules/mesh-build-go/buildstep/seatgen/clients.go b/modules/mesh-build-go/buildstep/seatgen/clients.go
new file mode 100644
index 00000000..13e85ac3
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/clients.go
@@ -0,0 +1,217 @@
+package seatgen
+
+import (
+ "encoding/json"
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ re "regexp"
+ "sort"
+ "strconv"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/seatdef"
+)
+
+// ClientRoot is where a seat's hand-written client packages live (ADR 0625 §4, §5; ADR 0627): one directory per
+// client, `go/clients/client`, a Go module of its own (ADR 0625 §3), so the seat set's importers
+// never import a client and a client's import of mesh-sdk forms no cycle. Compare leaves the tree to CheckClients.
+const ClientRoot = "go/clients"
+
+// RuntimeModule is mesh-sdk's Go module: the one module beside its own seat's contract a client may import (ADR 0625
+// §3, §4).
+const RuntimeModule = "git.novox.be/novox/mesh-sdk/go"
+
+// ClientGoModule is the Go module of a seat's client, beside its contract's.
+func ClientGoModule(root, seat string) string { return root + "/clients/" + pkgName(seat) + "client" }
+
+// a module's tool address (`.`) or call (`.` written with an underscore).
+var toolAddress = re.MustCompile(`^[a-z][a-z0-9-]*\.[a-z0-9]+_[a-z0-9_]+$`)
+
+// CheckClients is ADR 0627 §3's catalog check of the clients, and ADR 0625 §5's rule for the files beside them: every
+// directory under ClientRoot is the client of one seat defined whole, a Go module of its own at ClientGoModule; it
+// imports only the standard library, its own seat's contract module and mesh-sdk's runtime (never another seat's
+// contract or client); and no string in it names a module's subject (`mesh.mod.`), a module's tool, or another seat
+// (its name, its subjects, its `seat:` call). Each problem is named once, sorted.
+func CheckClients(dir string, m *Module) ([]string, error) {
+ var out []string
+ entries, err := os.ReadDir(filepath.Join(dir, ClientRoot))
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ bySeatPkg := map[string]string{}
+ for name, d := range m.Seats {
+ if d.Grain == seatdef.Whole {
+ bySeatPkg[pkgName(name)+"client"] = name
+ }
+ }
+ for _, e := range entries {
+ at := ClientRoot + "/" + e.Name()
+ if !e.IsDir() {
+ out = append(out, at+": a file under the clients' root, in no client")
+ continue
+ }
+ seat, ok := bySeatPkg[e.Name()]
+ if !ok {
+ out = append(out, at+": the client of no seat defined whole (a client is `client`)")
+ continue
+ }
+ mod, err := os.ReadFile(filepath.Join(dir, at, "go.mod"))
+ want := "module " + ClientGoModule(m.GoModule, seat)
+ if err != nil || strings.SplitN(string(mod), "\n", 2)[0] != want {
+ out = append(out, at+"/go.mod: not "+strconv.Quote(want)+": a client is a Go module of its own (ADR 0625 §3)")
+ }
+ problems, err := checkClient(dir, at, seat, m)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, problems...)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+func checkClient(dir, at, seat string, m *Module) ([]string, error) {
+ var out []string
+ own := SeatGoModule(m.GoModule, seat)
+ others := map[string]bool{}
+ for name := range m.Seats {
+ if name != seat {
+ others[name] = true
+ }
+ }
+ namesOther := func(s string) bool {
+ if others[s] {
+ return true
+ }
+ for name := range others {
+ if strings.HasPrefix(s, "mesh.seat."+name+".") || strings.HasPrefix(s, "seat:"+name+".") {
+ return true
+ }
+ }
+ return false
+ }
+ err := filepath.WalkDir(filepath.Join(dir, at), func(p string, e os.DirEntry, err error) error {
+ if err != nil || e.IsDir() || !strings.HasSuffix(p, ".go") {
+ return err
+ }
+ r := rel(dir, p)
+ f, err := parser.ParseFile(token.NewFileSet(), p, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return err
+ }
+ for _, im := range f.Imports {
+ path, _ := strconv.Unquote(im.Path.Value)
+ switch {
+ case !strings.Contains(strings.SplitN(path, "/", 2)[0], "."):
+ case path == own:
+ case path == RuntimeModule || strings.HasPrefix(path, RuntimeModule+"/"):
+ case strings.HasPrefix(path, m.GoModule+"/clients/"):
+ out = append(out, r+": imports "+path+", another seat's client (ADR 0625 §4)")
+ case path == m.GoModule || strings.HasPrefix(path, m.GoModule+"/"):
+ out = append(out, r+": imports "+path+", not its own seat's contract (ADR 0625 §4)")
+ default:
+ out = append(out, r+": imports "+path+": a client uses only its seat's contract and mesh-sdk's runtime (ADR 0625 §3)")
+ }
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ lit, ok := n.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return true
+ }
+ v, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ return true
+ }
+ switch {
+ case strings.HasPrefix(v, "mesh.mod."):
+ out = append(out, fmt.Sprintf("%s: names a module's subject %q (ADR 0625 §4)", r, v))
+ case toolAddress.MatchString(v):
+ out = append(out, fmt.Sprintf("%s: names a module's tool %q (ADR 0625 §4)", r, v))
+ case namesOther(v):
+ out = append(out, fmt.Sprintf("%s: names another seat %q (ADR 0625 §4)", r, v))
+ }
+ return true
+ })
+ return nil
+ })
+ return out, err
+}
+
+// TSSetArtifact is the seat set's npm package artifact, at ts/.
+const TSSetArtifact = "lib"
+
+// CheckArtifacts is the check of module.json's package artifacts against the tree (ADR 0625 §1, §3), per language.
+// Go: the root module is the artifact `go` at `go`, each seat defined whole the artifact `go-`
+// at its module's directory, each client present the artifact `go-client` at its directory.
+// npm: the seat set's package is the artifact `lib` at `ts`, each seat defined whole the artifact
+// `ts-` at TSSeatDir. No other package artifact of either language. Each problem is named once,
+// sorted.
+func CheckArtifacts(dir string, m *Module) ([]string, error) {
+ raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
+ if err != nil {
+ return nil, err
+ }
+ var mf struct {
+ Build struct {
+ Artifacts []struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Language string `json:"language"`
+ Paths []string `json:"paths"`
+ } `json:"artifacts"`
+ } `json:"build"`
+ }
+ if err := json.Unmarshal(raw, &mf); err != nil {
+ return nil, fmt.Errorf("module.json: %w", err)
+ }
+ want := map[string]map[string]string{
+ "go": {"go": "go"},
+ "typescript": {TSSetArtifact: "ts"},
+ }
+ for name, d := range m.Seats {
+ if d.Grain == seatdef.Whole {
+ want["go"]["go-"+pkgName(name)] = "go/seats/" + pkgName(name)
+ want["typescript"]["ts-"+pkgName(name)] = TSSeatDir(name)
+ }
+ }
+ if entries, err := os.ReadDir(filepath.Join(dir, ClientRoot)); err == nil {
+ for _, e := range entries {
+ if e.IsDir() {
+ want["go"]["go-"+e.Name()] = ClientRoot + "/" + e.Name()
+ }
+ }
+ }
+ said := map[string]string{"go": "Go", "typescript": "npm"}
+ var out []string
+ seen := map[string]bool{}
+ for _, a := range mf.Build.Artifacts {
+ paths, lang := want[a.Language]
+ if a.Kind != "package" || !lang {
+ continue
+ }
+ seen[a.Language+"/"+a.Name] = true
+ path, ok := paths[a.Name]
+ switch {
+ case !ok:
+ out = append(out, "module.json: the "+said[a.Language]+" package artifact "+a.Name+" is no seat's module, no client's and not the seat set's")
+ case len(a.Paths) != 1 || a.Paths[0] != path:
+ out = append(out, fmt.Sprintf("module.json: the artifact %s's paths are %v, not [%s]", a.Name, a.Paths, path))
+ }
+ }
+ for lang, paths := range want {
+ for name, path := range paths {
+ if !seen[lang+"/"+name] {
+ out = append(out, "module.json: no "+said[lang]+" package artifact "+name+" for "+path)
+ }
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
diff --git a/modules/mesh-build-go/buildstep/seatgen/clients_test.go b/modules/mesh-build-go/buildstep/seatgen/clients_test.go
new file mode 100644
index 00000000..9a979589
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/clients_test.go
@@ -0,0 +1,148 @@
+package seatgen
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// ADR 0627 §3's catalog check of a client, ADR 0625 §5's rule for what is hand-written, and module.json's Go package
+// artifacts against the tree (ADR 0625 §1, §3): each row writes one tree and names what the checks must say of it.
+func TestAClientUsesOnlyItsSeatAndTheRuntime(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)
+ }
+ const good = `package aregistryclient
+
+import (
+ "context"
+
+ "example.org/contracts/go/seats/aregistry"
+ "git.novox.be/novox/mesh-sdk/go/parts"
+)
+
+func Publish(ctx context.Context) string { _ = parts.MaxChunk; return "seat:" + aregistry.Seat + ".publish" }
+`
+ const ts = `,{"name":"lib","kind":"package","language":"typescript","paths":["ts"]},
+{"name":"ts-aregistry","kind":"package","language":"typescript","paths":["ts/seats/aregistry"]},
+{"name":"ts-bregistry","kind":"package","language":"typescript","paths":["ts/seats/bregistry"]}`
+ artifacts := func(extra string) string {
+ return `{"build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]},
+{"name":"go-aregistry","kind":"package","language":"go","paths":["go/seats/aregistry"]},
+{"name":"go-bregistry","kind":"package","language":"go","paths":["go/seats/bregistry"]}` + ts + extra + `]}}`
+ }
+ clientArtifact := `,{"name":"go-aregistryclient","kind":"package","language":"go","paths":["go/clients/aregistryclient"]}`
+ for _, c := range []struct {
+ name string
+ tree map[string]string
+ module string
+ want []string
+ }{
+ {"no client: the seats alone", nil, artifacts(""), nil},
+ {"a client of its own seat over the runtime", map[string]string{
+ "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n",
+ "go/clients/aregistryclient/client.go": good,
+ }, artifacts(clientArtifact), nil},
+ {"a client with no artifact, and an artifact of no module", map[string]string{
+ "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n",
+ "go/clients/aregistryclient/client.go": good,
+ }, artifacts(`,{"name":"go-extra","kind":"package","language":"go","paths":["go/extra"]}`),
+ []string{"module.json: no Go package artifact go-aregistryclient", "module.json: the Go package artifact go-extra is no seat's"}},
+ {"a seat's npm package with no artifact, one at another path, and one of no package", nil,
+ `{"build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]},
+{"name":"go-aregistry","kind":"package","language":"go","paths":["go/seats/aregistry"]},
+{"name":"go-bregistry","kind":"package","language":"go","paths":["go/seats/bregistry"]},
+{"name":"lib","kind":"package","language":"typescript","paths":["ts"]},
+{"name":"ts-aregistry","kind":"package","language":"typescript","paths":["ts"]},
+{"name":"ts-onestore","kind":"package","language":"typescript","paths":["ts/seats/onestore"]}]}}`,
+ []string{"module.json: no npm package artifact ts-bregistry", "module.json: the artifact ts-aregistry's paths are [ts], not [ts/seats/aregistry]",
+ "module.json: the npm package artifact ts-onestore is no seat's"}},
+ {"a client of no seat defined whole, and one not a module of its own", map[string]string{
+ "go/clients/onestoreclient/x.go": "package onestoreclient\n",
+ "go/clients/aregistryclient/go.mod": "module example.org/contracts/go\n",
+ }, artifacts(clientArtifact + `,{"name":"go-onestoreclient","kind":"package","language":"go","paths":["go/clients/onestoreclient"]}`),
+ []string{"go/clients/aregistryclient/go.mod: not", "go/clients/onestoreclient: the client of no seat defined whole"}},
+ {"a client using another seat, its client, a module's subject and tool", map[string]string{
+ "go/clients/aregistryclient/go.mod": "module example.org/contracts/go/clients/aregistryclient\n",
+ "go/clients/aregistryclient/client.go": `package aregistryclient
+
+import (
+ "example.org/contracts/go/seats/bregistry"
+ "example.org/contracts/go/clients/bregistryclient"
+ "github.com/some/thing"
+)
+
+var a, b, c, d, e = bregistry.Seat, bregistryclient.X, thing.Y, "mesh.mod.holder.event.x", "seat:b-registry.publish"
+var f = "records.records_search"
+`,
+ }, artifacts(clientArtifact), []string{
+ `client.go: imports example.org/contracts/go/clients/bregistryclient, another seat's client`,
+ `client.go: imports example.org/contracts/go/seats/bregistry, not its own seat's contract`,
+ `client.go: imports github.com/some/thing: a client uses only`,
+ `client.go: names a module's subject "mesh.mod.holder.event.x"`,
+ `client.go: names a module's tool "records.records_search"`,
+ `client.go: names another seat "seat:b-registry.publish"`,
+ }},
+ } {
+ dir := t.TempDir()
+ write := func(p, s string) {
+ full := filepath.Join(dir, p)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(s), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for p, b := range files {
+ write(p, string(b))
+ }
+ for p, s := range c.tree {
+ write(p, s)
+ }
+ write("module.json", c.module)
+ var got []string
+ cmp, err := Compare(dir, files)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, cmp...)
+ for _, check := range []func(string, *Module) ([]string, error){CheckClients, CheckArtifacts} {
+ more, err := check(dir, m)
+ if err != nil {
+ t.Fatalf("%s: %v", c.name, err)
+ }
+ got = append(got, more...)
+ }
+ if len(got) != len(c.want) {
+ t.Errorf("%s: said %d problems, want %d:\n%s", c.name, len(got), len(c.want), strings.Join(got, "\n"))
+ continue
+ }
+ for _, w := range c.want {
+ found := false
+ for _, g := range got {
+ found = found || strings.Contains(g, w)
+ }
+ if !found {
+ t.Errorf("%s: no problem says %q:\n%s", c.name, w, strings.Join(got, "\n"))
+ }
+ }
+ }
+ // A hand-written file outside a client is still out of step (ADR 0625 §5).
+ dir := t.TempDir()
+ for p, b := range files {
+ full := filepath.Join(dir, p)
+ _ = os.MkdirAll(filepath.Dir(full), 0o755)
+ _ = os.WriteFile(full, b, 0o644)
+ }
+ _ = os.WriteFile(filepath.Join(dir, "go/seats/aregistry/helper.go"), []byte("package aregistry\n"), 0o644)
+ if out, _ := Compare(dir, files); len(out) != 1 || !strings.Contains(out[0], "helper.go: not written by the generator") {
+ t.Errorf("a hand-written file beside a contract: %v", out)
+ }
+}
diff --git a/modules/mesh-build-go/buildstep/seatgen/gogen.go b/modules/mesh-build-go/buildstep/seatgen/gogen.go
new file mode 100644
index 00000000..7f4f0d23
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/gogen.go
@@ -0,0 +1,671 @@
+package seatgen
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "go/format"
+ "sort"
+ "strconv"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/seatdef"
+)
+
+const header = "// Code generated by seatgen from the seat definitions; DO NOT EDIT.\n\n"
+
+func goFiles(m *Module, out map[string][]byte) error {
+ // The seat set is the contracts module's root Go module; each seat defined whole is a Go module of its own beside
+ // it, so a change of one seat moves only its importers, whose fingerprint counts a Go module's go.mod and go.sum
+ // (ADR 0625 §1, §2). A seat's module imports nothing: the parts it includes are written into it whole.
+ out["go/go.mod"] = goMod(m.GoModule)
+ set, err := goSeatList(m)
+ if err != nil {
+ return err
+ }
+ out["go/seats/seats.go"] = set
+ for _, name := range m.Order {
+ d := m.Seats[name]
+ if d.Grain != seatdef.Whole {
+ continue
+ }
+ src, test, err := goWhole(m, d)
+ if err != nil {
+ return fmt.Errorf("seat %s: %w", name, err)
+ }
+ dir := "go/seats/" + pkgName(name) + "/"
+ out[dir+"go.mod"] = goMod(SeatGoModule(m.GoModule, name))
+ out[dir+"contract.go"] = src
+ if test != nil {
+ out[dir+"contract_test.go"] = test
+ }
+ }
+ return nil
+}
+
+// SeatGoModule is the Go module of a seat defined whole, beside the seat set's.
+func SeatGoModule(root, seat string) string { return root + "/seats/" + pkgName(seat) }
+
+func goMod(path string) []byte { return []byte("module " + path + "\n\ngo 1.26.0\n") }
+
+func sortedKeys[V any](m map[string]V) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func gofmt(b *bytes.Buffer) ([]byte, error) {
+ src, err := format.Source(b.Bytes())
+ if err != nil {
+ return nil, fmt.Errorf("the generated Go does not parse: %w\n%s", err, b.String())
+ }
+ return src, nil
+}
+
+// goSeatList is go/seats/seats.go: the seat list's types, every seat's name and the compiled list in reading order.
+func goSeatList(m *Module) ([]byte, error) {
+ var b bytes.Buffer
+ b.WriteString(header)
+ b.WriteString(`// Package seats is the mesh's seat list, generated from every seat's definition (design 26 *The seat set has one
+// source*): what each seat is, where it is held, the provision it delivers, its verbs with their inputs, what it
+// accepts and emits, what it receives and the paths it owns. mesh-sdk's runtime reads it; a seat its holder declares
+// itself has a name here and no entry in the list.
+package seats
+
+// Seat is one role the mesh defines.
+type Seat struct {
+ Name string
+ Scope string
+ Grain string
+ Replicated bool
+ Delivers string
+ Accepts []string
+ Emits []string
+ PulledByKind bool
+ Yields bool
+ Serves []Verb
+ Receives []Receivable
+ Paths []Path
+ HeldByTheEngine bool
+ Decision string
+}
+
+// Verb is one verb a seat serves: its input and answer as JSON Schema.
+type Verb struct {
+ Name string
+ Description string
+ Input map[string]any
+ Output map[string]any
+ Optional bool
+ Replaces []string
+}
+
+// Receivable is one kind of contribution a seat receives.
+type Receivable struct {
+ Kind string
+ Comment string
+ Dirs bool
+ Shape *Shape
+ Offered bool
+}
+
+// Shape is a kind given as data. Its checks are the runtime's.
+type Shape struct {
+ Places []PlaceField
+ Order string
+ Examples []map[string]any
+ Text bool
+}
+
+// PlaceField is one field a holder places by, and the values it takes.
+type PlaceField struct {
+ Field string
+ Values []string
+}
+
+// Path is one path on its machine a seat's holder owns.
+type Path struct {
+ Path string
+ StepsBackFor string
+}
+
+// The name of every seat defined.
+const (
+`)
+ for _, name := range m.Order {
+ fmt.Fprintf(&b, "\t%s = %q\n", ident(name), name)
+ }
+ b.WriteString(")\n\n// Names is every seat defined, in reading order, the ones their holders declare included.\nfunc Names() []string {\n\treturn []string{")
+ for _, name := range m.Order {
+ b.WriteString(ident(name) + ", ")
+ }
+ b.WriteString("}\n}\n\n// List is the seat list, in reading order: a new copy each call.\nfunc List() []Seat {\n\treturn []Seat{\n")
+ for _, name := range m.Order {
+ d := m.Seats[name]
+ if d.HolderDeclares {
+ continue
+ }
+ b.WriteString(goSeat(d))
+ }
+ b.WriteString("\t}\n}\n")
+ return gofmt(&b)
+}
+
+func goSeat(d seatdef.Definition) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "{Name: %s, Scope: %q, Grain: %q, Decision: %q", ident(d.Seat), d.Scope, d.Grain, d.Decision)
+ if d.Replicated {
+ b.WriteString(", Replicated: true")
+ }
+ if d.Delivers != "" {
+ fmt.Fprintf(&b, ", Delivers: %q", d.Delivers)
+ }
+ if len(d.Accepts) > 0 {
+ b.WriteString(", Accepts: " + goLit(anyStrings(seatdef.MessageNames(d.Accepts))))
+ }
+ if len(d.Events) > 0 {
+ b.WriteString(", Emits: " + goLit(anyStrings(seatdef.MessageNames(d.Events))))
+ }
+ if d.PulledByKind {
+ b.WriteString(", PulledByKind: true")
+ }
+ if d.Yields {
+ b.WriteString(", Yields: true")
+ }
+ if d.HeldByTheEngine {
+ b.WriteString(", HeldByTheEngine: true")
+ }
+ if len(d.Verbs) > 0 {
+ b.WriteString(",\n\t\tServes: []Verb{\n")
+ for _, v := range d.Verbs {
+ fmt.Fprintf(&b, "\t\t\t{Name: %q, Description: %q", v.Name, v.Description)
+ if v.Optional {
+ b.WriteString(", Optional: true")
+ }
+ if v.Input != nil {
+ b.WriteString(",\n\t\t\t\tInput: " + goLit(resolveRefs(v.Input, d.Defs)))
+ }
+ if v.Answer != nil {
+ b.WriteString(",\n\t\t\t\tOutput: " + goLit(resolveRefs(v.Answer, d.Defs)))
+ }
+ if len(v.Replaces) > 0 {
+ b.WriteString(", Replaces: " + goLit(anyStrings(v.Replaces)))
+ }
+ b.WriteString("},\n")
+ }
+ b.WriteString("\t\t}")
+ }
+ if len(d.Receives) > 0 {
+ b.WriteString(",\n\t\tReceives: []Receivable{")
+ for _, r := range d.Receives {
+ fmt.Fprintf(&b, "{Kind: %q, Comment: %q", r.Kind, r.Comment)
+ if r.Dirs {
+ b.WriteString(", Dirs: true")
+ }
+ if r.Offered {
+ b.WriteString(", Offered: true")
+ }
+ if s := r.Shape; s != nil {
+ b.WriteString(", Shape: &Shape{")
+ if len(s.Places) > 0 {
+ b.WriteString("Places: []PlaceField{")
+ for _, p := range s.Places {
+ fmt.Fprintf(&b, "{Field: %q, Values: %s}, ", p.Field, goLit(anyStrings(p.Values)))
+ }
+ b.WriteString("}, ")
+ }
+ if s.Order != "" {
+ fmt.Fprintf(&b, "Order: %q, ", s.Order)
+ }
+ if len(s.Examples) > 0 {
+ b.WriteString("Examples: []map[string]any{")
+ for _, e := range s.Examples {
+ b.WriteString(goLit(e) + ", ")
+ }
+ b.WriteString("}, ")
+ }
+ if s.Text {
+ b.WriteString("Text: true")
+ }
+ b.WriteString("}")
+ }
+ b.WriteString("}, ")
+ }
+ b.WriteString("}")
+ }
+ if len(d.Paths) > 0 {
+ b.WriteString(", Paths: []Path{")
+ for _, p := range d.Paths {
+ fmt.Fprintf(&b, "{Path: %q, StepsBackFor: %q}, ", p.Path, p.StepsBackFor)
+ }
+ b.WriteString("}")
+ }
+ b.WriteString("},\n")
+ return b.String()
+}
+
+func anyStrings(s []string) []any {
+ out := make([]any, len(s))
+ for i, v := range s {
+ out[i] = v
+ }
+ return out
+}
+
+// goLit is a JSON value as a Go literal, in the form encoding/json decodes it, but for a list of strings alone,
+// which is a []string as the seat list wrote it: a map's keys sorted, a number a float64.
+func goLit(v any) string {
+ switch x := v.(type) {
+ case nil:
+ return "nil"
+ case string:
+ return strconv.Quote(x)
+ case bool:
+ return strconv.FormatBool(x)
+ case float64:
+ return "float64(" + strconv.FormatFloat(x, 'g', -1, 64) + ")"
+ case int:
+ return "float64(" + strconv.Itoa(x) + ")"
+ case json.Number:
+ return "float64(" + x.String() + ")"
+ case []string:
+ return goLit(anyStrings(x))
+ case []any:
+ allStrings := len(x) > 0
+ for _, e := range x {
+ if _, ok := e.(string); !ok {
+ allStrings = false
+ }
+ }
+ var b strings.Builder
+ if allStrings {
+ b.WriteString("[]string{")
+ } else {
+ b.WriteString("[]any{")
+ }
+ for i, e := range x {
+ if i > 0 {
+ b.WriteString(", ")
+ }
+ b.WriteString(goLit(e))
+ }
+ b.WriteString("}")
+ return b.String()
+ case map[string]any:
+ var b strings.Builder
+ b.WriteString("map[string]any{")
+ for i, k := range sortedKeys(x) {
+ if i > 0 {
+ b.WriteString(", ")
+ }
+ b.WriteString(strconv.Quote(k) + ": " + goLit(x[k]))
+ }
+ b.WriteString("}")
+ return b.String()
+ }
+ // Any other value (a typed list from a definition read in Go) goes through JSON once.
+ raw, _ := json.Marshal(v)
+ var back any
+ _ = json.Unmarshal(raw, &back)
+ return goLit(back)
+}
+
+// declareAll declares a definition's types: its own named types, then each verb's input and answer, each accept,
+// event, holder event, record and provision; a name given by a part (in fromPart) is an alias of the part's.
+func declareAll(d seatdef.Definition, fromPart map[string]string) (*typer, error) {
+ tp := newTyper()
+ decl := func(name, desc string, s map[string]any) error {
+ if s == nil {
+ return nil
+ }
+ if _, ok := fromPart[name]; ok {
+ tp.taken[name] = true
+ return nil
+ }
+ return tp.declare(name, desc, s)
+ }
+ for _, n := range d.DefNames() {
+ if err := decl(n, "", d.Defs[n]); err != nil {
+ return nil, err
+ }
+ }
+ for _, v := range d.Verbs {
+ if err := decl(ident(v.Name)+"Input", "is what "+v.Name+" takes.", v.Input); err != nil {
+ return nil, err
+ }
+ if err := decl(ident(v.Name)+"Answer", "is what "+v.Name+" answers.", v.Answer); err != nil {
+ return nil, err
+ }
+ }
+ for _, a := range d.Accepts {
+ if err := decl(ident(a.Name)+"Accept", "is the body of "+a.Name+".", a.Body); err != nil {
+ return nil, err
+ }
+ }
+ for _, e := range d.Events {
+ if err := decl(ident(e.Name)+"Event", "is the body of the event "+e.Name+".", e.Body); err != nil {
+ return nil, err
+ }
+ }
+ for _, e := range d.HolderEvents {
+ if err := decl(ident(e.Name)+"HolderEvent", "is the body of the holder's event "+e.Name+".", e.Body); err != nil {
+ 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 _, p := range d.Provisions {
+ if err := decl(ident(p.Name)+"Form", "is the form of the provision "+p.Name+".", p.Form); err != nil {
+ return nil, err
+ }
+ }
+ return tp, nil
+}
+
+func oneLine(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return "no description."
+ }
+ return strings.ReplaceAll(s, "\n", " ")
+}
+
+func writeNames(b *bytes.Buffer, prefix string, names []string) {
+ if len(names) == 0 {
+ return
+ }
+ b.WriteString("const (\n")
+ for _, n := range names {
+ fmt.Fprintf(b, "\t%s%s = %q\n", prefix, ident(n), n)
+ }
+ b.WriteString(")\n\n")
+}
+
+func writeList(b *bytes.Buffer, fn, prefix, what string, names []string) {
+ if len(names) == 0 {
+ return
+ }
+ fmt.Fprintf(b, "// %s is %s: a new copy each call.\nfunc %s() []string {\n\treturn []string{", fn, what, fn)
+ for i, n := range names {
+ if i > 0 {
+ b.WriteString(", ")
+ }
+ b.WriteString(prefix + ident(n))
+ }
+ b.WriteString("}\n}\n\n")
+}
+
+// goWhole is a whole seat's package: its names, subjects and types, and the test of its fixtures.
+func goWhole(m *Module, d seatdef.Definition) ([]byte, []byte, error) {
+ fromPart := map[string]string{}
+ tp, err := declareAll(d, fromPart)
+ if err != nil {
+ return nil, nil, err
+ }
+ pkg := pkgName(d.Seat)
+ var b bytes.Buffer
+ 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)
+ 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))
+ writeNames(&b, "Event", seatdef.MessageNames(d.Events))
+ writeList(&b, "Verbs", "Verb", "every verb the seat serves, in its definition's order", seatdef.VerbNames(d.Verbs))
+ writeList(&b, "Events", "Event", "every event published on the seat's subject", seatdef.MessageNames(d.Events))
+ if f := d.Family; f != nil {
+ fmt.Fprintf(&b, "// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.\nconst (\n\tFamily = %q\n\tFamilyMaxAgeDays = %d\n)\n\n", f.Name, f.MaxAgeDays)
+ }
+ for _, e := range d.Events {
+ subj := seatdef.EventSubject(d.Seat, e.Name)
+ if e.ByCaller {
+ fmt.Fprintf(&b, "// Subject%s is where %s is published for one caller.\nfunc Subject%s(caller string) string { return %q + caller }\n\n",
+ ident(e.Name), e.Name, ident(e.Name), subj+".")
+ } else {
+ fmt.Fprintf(&b, "// Subject%s is where %s is published.\nconst Subject%s = %q\n\n", ident(e.Name), e.Name, ident(e.Name), subj)
+ }
+ }
+ for _, a := range d.Accepts {
+ subj := seatdef.AcceptSubject(d.Seat, a.Name)
+ if a.ByCaller {
+ fmt.Fprintf(&b, "// AcceptSubject%s is where one caller sends %s.\nfunc AcceptSubject%s(caller string) string { return %q + caller }\n\n",
+ ident(a.Name), a.Name, ident(a.Name), subj+".")
+ } else {
+ fmt.Fprintf(&b, "// AcceptSubject%s is where %s is sent.\nconst AcceptSubject%s = %q\n\n", ident(a.Name), a.Name, ident(a.Name), subj)
+ }
+ }
+ if len(d.HolderEvents) > 0 {
+ b.WriteString("// The holder's own events, listed beside the seat and published under the holder's subjects (ADR 0620 §2).\nconst (\n")
+ for _, e := range d.HolderEvents {
+ fmt.Fprintf(&b, "\tHolderEvent%s = %q\n", ident(e.Name), e.Name)
+ }
+ b.WriteString(")\n\n")
+ for _, e := range d.HolderEvents {
+ before, after, _ := strings.Cut(e.Subject, "")
+ fmt.Fprintf(&b, "// HolderSubject%s is where the holder publishes %s", ident(e.Name), e.Name)
+ if e.Family != "" {
+ fmt.Fprintf(&b, ", kept in %s", e.Family)
+ }
+ 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 err := goTypes(&b, tp, map[string]*typ{}); err != nil {
+ return nil, nil, err
+ }
+ src, err := gofmt(&b)
+ if err != nil {
+ return nil, nil, err
+ }
+ test, err := goFixtures(m, d, pkg, tp, fromPart)
+ 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{}
+ for k, v := range also {
+ kinds[k] = v
+ }
+ for _, t := range tp.types {
+ kinds[t.name] = t.t
+ }
+ for _, t := range tp.types {
+ desc := strings.TrimSpace(t.desc)
+ if desc == "" {
+ desc = "is a type of the contract."
+ } else if !strings.HasPrefix(desc, "is ") {
+ desc = "is " + strings.TrimSuffix(desc, ".") + "."
+ }
+ fmt.Fprintf(b, "// %s %s\n", t.name, oneLine(desc))
+ if t.t.kind != "struct" {
+ fmt.Fprintf(b, "type %s = %s\n\n", t.name, goType(t.t, kinds, true))
+ continue
+ }
+ fmt.Fprintf(b, "type %s struct {\n", t.name)
+ for _, f := range t.t.fields {
+ if f.desc != "" {
+ fmt.Fprintf(b, "\t// %s\n", oneLine(f.desc))
+ }
+ tag := f.json
+ if !f.required {
+ tag += ",omitempty"
+ }
+ fmt.Fprintf(b, "\t%s %s `json:%q`\n", ident(f.json), goType(f.t, kinds, f.required), tag)
+ }
+ b.WriteString("}\n\n")
+ }
+ return nil
+}
+
+func goType(t *typ, kinds map[string]*typ, required bool) string {
+ switch t.kind {
+ case "string":
+ return "string"
+ case "integer":
+ return "int64"
+ case "number":
+ return "float64"
+ case "boolean":
+ return "bool"
+ case "object":
+ return "map[string]any"
+ case "map":
+ return "map[string]" + goType(t.elem, kinds, true)
+ case "slice":
+ return "[]" + goType(t.elem, kinds, true)
+ case "ref":
+ if k, ok := kinds[t.ref]; ok && k.kind == "struct" && !required {
+ return "*" + t.ref
+ }
+ return t.ref
+ }
+ return "any"
+}
+
+// goFixtures is the test that every fixture of the seat decodes into its type and reads back as its wire says, and
+// that the subject it names is the one the contract gives.
+func goFixtures(m *Module, d seatdef.Definition, pkg string, tp *typer, fromPart map[string]string) ([]byte, error) {
+ fx := m.Fixtures[d.Seat]
+ if len(fx) == 0 {
+ return nil, nil
+ }
+ has := map[string]bool{}
+ for _, t := range tp.types {
+ has[t.name] = true
+ }
+ for n := range fromPart {
+ has[n] = true
+ }
+ var b bytes.Buffer
+ b.WriteString(header)
+ fmt.Fprintf(&b, "package %s\n\nimport (\n\t\"encoding/json\"\n\t\"reflect\"\n\t\"regexp\"\n\t\"testing\"\n)\n\n", pkg)
+ fmt.Fprintf(&b, "// TestFixtures reads every fixture of seats/%s/fixtures: each decodes into its type, a field the type does not\n"+
+ "// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.\n", d.Seat)
+ b.WriteString("func TestFixtures(t *testing.T) {\n\tcases := []struct {\n\t\tname, subject, given, wire string\n\t\tdecode func([]byte) (any, error)\n\t\tsubjectIs *regexp.Regexp\n\t}{\n")
+ for _, f := range fx {
+ kind, name, _ := f.Instance()
+ typeName, pattern, err := fixtureTarget(d, kind, name)
+ if err != nil {
+ return nil, fmt.Errorf("fixture %q: %w", f.Name, err)
+ }
+ given, _ := json.Marshal(f.Given)
+ wire := given
+ if f.Wire != nil {
+ wire, _ = json.Marshal(f.Wire)
+ }
+ decoder := "func(b []byte) (any, error) { var v map[string]any; err := json.Unmarshal(b, &v); return v, err }"
+ if has[typeName] {
+ decoder = fmt.Sprintf("func(b []byte) (any, error) { var v %s; err := json.Unmarshal(b, &v); return v, err }", typeName)
+ }
+ subjectIs := "nil"
+ if pattern != "" {
+ subjectIs = "regexp.MustCompile(" + strconv.Quote(pattern) + ")"
+ } else if f.Subject != "" {
+ return nil, fmt.Errorf("fixture %q names a subject, and a verb's message has none in the contract", f.Name)
+ }
+ if pattern != "" && f.Subject == "" {
+ return nil, fmt.Errorf("fixture %q of %s names no subject (design 26: each event's subject is named)", f.Name, f.Of)
+ }
+ fmt.Fprintf(&b, "\t\t{name: %q, subject: %q, given: %s, wire: %s,\n\t\t\tdecode: %s, subjectIs: %s},\n",
+ f.Name, f.Subject, strconv.Quote(string(given)), strconv.Quote(string(wire)), decoder, subjectIs)
+ }
+ b.WriteString(` }
+ for _, c := range cases {
+ v, err := c.decode([]byte(c.given))
+ if err != nil {
+ t.Errorf("%s: does not decode: %v", c.name, err)
+ continue
+ }
+ back, err := json.Marshal(v)
+ if err != nil {
+ t.Errorf("%s: does not encode: %v", c.name, err)
+ continue
+ }
+ var got, want any
+ if err := json.Unmarshal(back, &got); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
+ }
+ if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
+ t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
+ }
+ }
+}
+`)
+ return gofmt(&b)
+}
+
+// fixtureTarget is the type a fixture decodes into and the pattern its subject must match, if it has one.
+func fixtureTarget(d seatdef.Definition, kind, name string) (string, string, error) {
+ switch kind {
+ case "verb-input", "verb-answer":
+ for _, v := range d.Verbs {
+ if v.Name == name {
+ if kind == "verb-input" {
+ return ident(name) + "Input", "", nil
+ }
+ return ident(name) + "Answer", "", nil
+ }
+ }
+ case "accept", "event":
+ ms, subj := d.Accepts, seatdef.AcceptSubject(d.Seat, name)
+ suffix := "Accept"
+ if kind == "event" {
+ ms, subj, suffix = d.Events, seatdef.EventSubject(d.Seat, name), "Event"
+ }
+ for _, msg := range ms {
+ if msg.Name == name {
+ p := "^" + regexp(subj)
+ if msg.ByCaller {
+ p += `\.[A-Za-z0-9_-]+`
+ }
+ return ident(name) + suffix, p + "$", nil
+ }
+ }
+ case "holder-event":
+ for _, e := range d.HolderEvents {
+ if e.Name == name {
+ before, after, _ := strings.Cut(e.Subject, "")
+ p := "^" + regexp(before)
+ if strings.Contains(e.Subject, "") {
+ p += "[a-z0-9-]+" + regexp(after)
+ }
+ return ident(name) + "HolderEvent", p + "$", nil
+ }
+ }
+ }
+ return "", "", fmt.Errorf("the definition has no %s %q", kind, name)
+}
+
+func regexp(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ if strings.ContainsRune(`\.+*?()|[]{}^$`, r) {
+ b.WriteRune('\\')
+ }
+ b.WriteRune(r)
+ }
+ return b.String()
+}
diff --git a/modules/mesh-build-go/buildstep/seatgen/layout_test.go b/modules/mesh-build-go/buildstep/seatgen/layout_test.go
new file mode 100644
index 00000000..b7c5b642
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/layout_test.go
@@ -0,0 +1,139 @@
+package seatgen
+
+import (
+ "encoding/json"
+ "go/parser"
+ "go/token"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/seatdef"
+)
+
+// TestEachSeatDefinedWholeIsAGoModuleOfItsOwn is ADR 0625's generator check (§1, §2, §3): the seat set is one package in
+// the root Go module, each seat defined whole is a Go module of its own holding its package alone, the parts it
+// includes are written into it, and no generated Go file imports anything outside the standard library.
+func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(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)
+ }
+ mods := map[string]string{}
+ for p, b := range files {
+ if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, "go.mod") {
+ mods[strings.TrimSuffix(p, "go.mod")] = strings.SplitN(string(b), "\n", 2)[0]
+ }
+ }
+ want := map[string]string{
+ "go/": "module example.org/contracts/go",
+ "go/seats/aregistry/": "module example.org/contracts/go/seats/aregistry",
+ "go/seats/bregistry/": "module example.org/contracts/go/seats/bregistry",
+ }
+ for dir, line := range want {
+ if mods[dir] != line {
+ t.Errorf("%sgo.mod says %q, want %q", dir, mods[dir], line)
+ }
+ }
+ // Every seat the definitions mark whole has its own module, whatever the seat set says of it (ADR 0627: the seat
+ // set marks ten whole, and none is without its package).
+ for name, d := range m.Seats {
+ if d.Grain == seatdef.Whole && mods["go/seats/"+pkgName(name)+"/"] == "" {
+ t.Errorf("the seat %s is defined whole and has no Go module of its own", name)
+ }
+ }
+ if len(mods) != len(want) {
+ t.Errorf("Go modules %v, want %v", mods, want)
+ }
+ for p, b := range files {
+ if !strings.HasPrefix(p, "go/") || !strings.HasSuffix(p, ".go") {
+ continue
+ }
+ dir := p[:strings.LastIndex(p, "/")+1]
+ if dir != "go/seats/" && mods[dir] == "" {
+ t.Errorf("%s is in no seat's own Go module, nor the seat set's package", p)
+ }
+ f, err := parser.ParseFile(token.NewFileSet(), p, b, parser.ImportsOnly)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, im := range f.Imports {
+ if path := strings.Trim(im.Path.Value, `"`); strings.Contains(strings.SplitN(path, "/", 2)[0], ".") {
+ t.Errorf("%s imports %s: a generated package imports nothing outside the standard library", p, path)
+ }
+ }
+ }
+ for _, seat := range []string{"aregistry", "bregistry"} {
+ src := string(files["go/seats/"+seat+"/contract.go"])
+ if !strings.Contains(src, "type Ref struct") || !strings.Contains(src, "type PublishedEvent") {
+ t.Errorf("%s does not hold the part's types whole", seat)
+ }
+ }
+ if !strings.Contains(string(files["go/seats/seats.go"]), "OneStore") {
+ t.Error("the seat set does not name the seat at the seat list's grain")
+ }
+
+ // The npm side (ADR 0625 §1): the indexer counts an npm package a lock resolves, so each seat defined whole is an
+ // npm package of its own (`-` at ts/seats/), holding its parts' types whole and
+ // importing nothing outside itself; the seat set's package (ts/) holds the seat list alone.
+ pkgs := map[string]string{}
+ for p, b := range files {
+ if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, "package.json") {
+ var pj struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(b, &pj); err != nil {
+ t.Fatalf("%s: %v", p, err)
+ }
+ pkgs[strings.TrimSuffix(p, "package.json")] = pj.Name
+ }
+ }
+ wantPkgs := map[string]string{
+ "ts/": "@example/contracts",
+ "ts/seats/aregistry/": "@example/contracts-a-registry",
+ "ts/seats/bregistry/": "@example/contracts-b-registry",
+ }
+ if len(pkgs) != len(wantPkgs) {
+ t.Errorf("npm packages %v, want %v", pkgs, wantPkgs)
+ }
+ for dir, name := range wantPkgs {
+ if pkgs[dir] != name {
+ t.Errorf("%spackage.json names %q, want %q", dir, pkgs[dir], name)
+ }
+ }
+ for p, b := range files {
+ if !strings.HasPrefix(p, "ts/") || !strings.HasSuffix(p, ".ts") {
+ continue
+ }
+ dir := ""
+ for d := range pkgs {
+ if strings.HasPrefix(p, d) && len(d) > len(dir) {
+ dir = d
+ }
+ }
+ if dir == "" || !strings.HasPrefix(p, dir+"src/") {
+ t.Errorf("%s is in no npm package's src", p)
+ }
+ for _, line := range strings.Split(string(b), "\n") {
+ i := strings.Index(line, ` from "`)
+ if l := strings.TrimSpace(line); i >= 0 && (strings.HasPrefix(l, "import") || strings.HasPrefix(l, "export")) {
+ from := strings.Trim(line[i+len(` from `):], `";`)
+ if !strings.HasPrefix(from, "./") {
+ t.Errorf("%s imports %s: a generated npm package imports only its own files", p, from)
+ }
+ }
+ }
+ }
+ for _, seat := range []string{"aregistry", "bregistry"} {
+ if !strings.Contains(string(files["ts/seats/"+seat+"/src/index.ts"]), "export type PublishedEvent = registry.PublishedEvent") ||
+ !strings.Contains(string(files["ts/seats/"+seat+"/src/parts/registry.ts"]), "export type PublishedEvent") {
+ t.Errorf("the npm package of %s does not hold the part's types whole", seat)
+ }
+ }
+ if ix := string(files["ts/src/index.ts"]); strings.Contains(ix, "Registry") || !strings.Contains(ix, "seats") {
+ t.Errorf("the seat set's npm package exports a seat's contract or not the seat list:\n%s", ix)
+ }
+}
diff --git a/modules/mesh-build-go/buildstep/seatgen/load.go b/modules/mesh-build-go/buildstep/seatgen/load.go
new file mode 100644
index 00000000..43eb6548
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/load.go
@@ -0,0 +1,210 @@
+// Package seatgen writes a contracts module's Go and TypeScript from its seat definitions (design 26 *The
+// generator*): the seat list every reader compiles, and for each seat defined whole its names, subjects and message
+// types, with a test of its fixtures. The same input gives the same bytes. It is the build modules' logic (the Go
+// build module's for Go, the Node build module's for TypeScript); this copy wrote mesh-contracts' first files and is
+// handed to them as it stands.
+package seatgen
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/seatdef"
+)
+
+// Module is a contracts module read from its directory.
+type Module struct {
+ // GoModule is the Go module path of the generated Go tree (`/go`).
+ GoModule string
+ // TSPackage is the npm package's name.
+ TSPackage string
+ // Order is the seat list's reading order (seats/index.json).
+ Order []string
+ // Seats are every definition, resolved, by name.
+ Seats map[string]seatdef.Definition
+ // Raw are every definition as written, its parts not taken in.
+ Raw map[string]seatdef.Definition
+ // Parts are every part, by name.
+ Parts map[string]seatdef.Part
+ // Fixtures are each seat's fixtures, by seat, in file order.
+ Fixtures map[string][]seatdef.Fixture
+}
+
+// Load reads a contracts module: `seats/index.json`, `seats//seat.json`, `seats//fixtures/*.json`
+// and `parts/*.json`. A seat with a directory and not in the index, or in the index with no directory, is refused.
+func Load(dir, goModule, tsPackage string) (*Module, error) {
+ m := &Module{GoModule: goModule, TSPackage: tsPackage, Seats: map[string]seatdef.Definition{},
+ Raw: map[string]seatdef.Definition{}, Parts: map[string]seatdef.Part{}, Fixtures: map[string][]seatdef.Fixture{}}
+ raw, err := os.ReadFile(filepath.Join(dir, "seats", "index.json"))
+ if err != nil {
+ return nil, err
+ }
+ ix, err := seatdef.ParseIndex(raw)
+ if err != nil {
+ return nil, fmt.Errorf("seats/index.json: %w", err)
+ }
+ m.Order = ix.Seats
+ parts, _ := filepath.Glob(filepath.Join(dir, "parts", "*.json"))
+ sort.Strings(parts)
+ for _, p := range parts {
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return nil, err
+ }
+ part, err := seatdef.ParsePart(raw)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", rel(dir, p), err)
+ }
+ if want := strings.TrimSuffix(filepath.Base(p), ".json"); part.Part != want {
+ return nil, fmt.Errorf("%s: names part %q", rel(dir, p), part.Part)
+ }
+ m.Parts[part.Part] = part
+ }
+ dirs, _ := filepath.Glob(filepath.Join(dir, "seats", "*", "seat.json"))
+ inIndex := map[string]bool{}
+ for _, s := range m.Order {
+ inIndex[s] = true
+ }
+ for _, p := range dirs {
+ name := filepath.Base(filepath.Dir(p))
+ if !inIndex[name] {
+ return nil, fmt.Errorf("%s: seat %q is not in seats/index.json", rel(dir, p), name)
+ }
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return nil, err
+ }
+ d, err := seatdef.Parse(raw)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", rel(dir, p), err)
+ }
+ if d.Seat != name {
+ return nil, fmt.Errorf("%s: names seat %q", rel(dir, p), d.Seat)
+ }
+ m.Raw[name] = d
+ resolved, err := seatdef.Resolve(d, m.Parts)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", rel(dir, p), err)
+ }
+ m.Seats[name] = resolved
+ fx, _ := filepath.Glob(filepath.Join(filepath.Dir(p), "fixtures", "*.json"))
+ sort.Strings(fx)
+ for _, f := range fx {
+ raw, err := os.ReadFile(f)
+ if err != nil {
+ return nil, err
+ }
+ cases, err := seatdef.ParseFixtures(raw)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", rel(dir, f), err)
+ }
+ m.Fixtures[name] = append(m.Fixtures[name], cases...)
+ }
+ }
+ for _, s := range m.Order {
+ if _, ok := m.Seats[s]; !ok {
+ return nil, fmt.Errorf("seats/index.json names %q, which has no seats/%s/seat.json", s, s)
+ }
+ }
+ return m, nil
+}
+
+func rel(dir, p string) string {
+ if r, err := filepath.Rel(dir, p); err == nil {
+ return r
+ }
+ return p
+}
+
+// Generate is every file the module's generated trees hold, by path from the module's root.
+func Generate(m *Module) (map[string][]byte, error) {
+ out := map[string][]byte{}
+ if err := goFiles(m, out); err != nil {
+ return nil, err
+ }
+ if err := tsFiles(m, out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// GeneratedRoots are the directories every file of which the generator writes: a file there it did not write is
+// out of step (design 26: the compare step).
+var GeneratedRoots = []string{"go", "ts"}
+
+// Compare is every path out of step between what the generator writes and the tree in dir: a file that differs, one
+// missing, or one under a generated root the generator did not write, each named once, sorted.
+func Compare(dir string, files map[string][]byte) ([]string, error) {
+ var out []string
+ for p, want := range files {
+ got, err := os.ReadFile(filepath.Join(dir, p))
+ switch {
+ case os.IsNotExist(err):
+ out = append(out, p+": missing")
+ case err != nil:
+ return nil, err
+ case string(got) != string(want):
+ out = append(out, p+": differs")
+ }
+ }
+ for _, root := range GeneratedRoots {
+ err := filepath.WalkDir(filepath.Join(dir, root), func(p string, e os.DirEntry, err error) error {
+ if err != nil {
+ if os.IsNotExist(err) {
+ return filepath.SkipDir
+ }
+ return err
+ }
+ if e.IsDir() {
+ if e.Name() == "node_modules" || e.Name() == "dist" || rel(dir, p) == ClientRoot {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ r := rel(dir, p)
+ if _, ok := files[r]; !ok {
+ out = append(out, r+": not written by the generator")
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// pkgName is a seat's or part's Go package: its name without hyphens.
+func pkgName(name string) string { return strings.ReplaceAll(name, "-", "") }
+
+// ident is a name in Go's exported form: mesh-broker → MeshBroker, publish-part → PublishPart, preview.written →
+// PreviewWritten, local-name → LocalName, id → ID.
+func ident(name string) string {
+ var b strings.Builder
+ for _, w := range strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' || r == ' ' }) {
+ switch w {
+ case "id":
+ b.WriteString("ID")
+ case "url":
+ b.WriteString("URL")
+ case "mib":
+ b.WriteString("MiB")
+ default:
+ b.WriteString(strings.ToUpper(w[:1]) + w[1:])
+ }
+ }
+ return b.String()
+}
+
+// tsName is a name in TypeScript's camel form for a constant: publish-part → publishPart.
+func tsName(name string) string {
+ id := ident(name)
+ if strings.HasPrefix(id, "ID") {
+ return "id" + id[2:]
+ }
+ return strings.ToLower(id[:1]) + id[1:]
+}
diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/parts/registry.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/parts/registry.json
new file mode 100644
index 00000000..e06ac049
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/parts/registry.json
@@ -0,0 +1,3 @@
+{"format":1,"part":"registry","verbs":[{"name":"publish","input":{"$ref":"#/defs/Ref"},"answer":{"type":"object"}}],
+ "events":[{"name":"published","body":{"$ref":"#/defs/Ref"}}],
+ "defs":{"Ref":{"type":"object","properties":{"digest":{"type":"string"}},"required":["digest"]}}}
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
new file mode 100644
index 00000000..73235370
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/a-registry/seat.json
@@ -0,0 +1,3 @@
+{"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}}
diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/b-registry/seat.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/b-registry/seat.json
new file mode 100644
index 00000000..cff301db
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/b-registry/seat.json
@@ -0,0 +1,3 @@
+{"format":1,"seat":"b-registry","scope":"mesh","grain":"whole","decision":"ADR 1","includes":["registry"],
+ "verbs":[{"name":"hold","input":{"type":"object"},"answer":{"type":"object"}}],
+ "family":{"name":"b-registry.events","max-age-days":7}}
diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/index.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/index.json
new file mode 100644
index 00000000..5218cd34
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/index.json
@@ -0,0 +1 @@
+{"format":1,"seats":["one-store","a-registry","b-registry"]}
diff --git a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/one-store/seat.json b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/one-store/seat.json
new file mode 100644
index 00000000..3e0501a6
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/one-store/seat.json
@@ -0,0 +1 @@
+{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}
diff --git a/modules/mesh-build-go/buildstep/seatgen/tsgen.go b/modules/mesh-build-go/buildstep/seatgen/tsgen.go
new file mode 100644
index 00000000..c8fea5cb
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/tsgen.go
@@ -0,0 +1,313 @@
+package seatgen
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/seatdef"
+)
+
+const tsHeader = "// Code generated by seatgen from the seat definitions; DO NOT EDIT.\n\n"
+
+// TSSeatDir is where a seat defined whole has its npm package (ADR 0625 §1): the indexer counts the npm package a
+// lock resolves, so each such seat is a package of its own and a module is rebuilt only for the seats it uses.
+func TSSeatDir(seat string) string { return "ts/seats/" + pkgName(seat) }
+
+// TSSeatPackage is the npm package name of a seat defined whole, beside the seat set's package root.
+func TSSeatPackage(root, seat string) string { return root + "-" + seat }
+
+const tsConfig = `{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src/**/*.ts"]
+}
+`
+
+// tsPackage writes one npm package's package.json and tsconfig.json at dir.
+func tsPackage(out map[string][]byte, dir, name, description string) {
+ pkg := map[string]any{
+ "name": name, "version": "0.1.0", "type": "module",
+ "description": description,
+ "exports": map[string]any{".": "./dist/index.js"},
+ "files": []any{"dist"},
+ "scripts": map[string]any{"build": "tsc", "prepare": "tsc"},
+ "devDependencies": map[string]any{"typescript": "^5.6.0"},
+ }
+ raw, _ := json.MarshalIndent(pkg, "", " ")
+ out[dir+"/package.json"] = append(raw, '\n')
+ out[dir+"/tsconfig.json"] = []byte(tsConfig)
+}
+
+// tsFiles writes the npm side: the seat set's package at ts/ (the seat list alone, ADR 0625 §2) and one package per
+// seat defined whole at TSSeatDir (ADR 0625 §1), holding the parts it includes whole and importing nothing outside it.
+func tsFiles(m *Module, out map[string][]byte) error {
+ tsPackage(out, "ts", m.TSPackage, "The mesh's seat set, generated from every seat's definition.")
+ out["ts/src/index.ts"] = []byte(tsHeader + "export * as seats from \"./seats.js\";\n")
+ out["ts/src/seats.ts"] = tsSeatList(m)
+ partTypes := map[string]*typer{}
+ partFiles := map[string][]byte{}
+ for _, name := range sortedKeys(m.Parts) {
+ p := m.Parts[name]
+ d := seatdef.Definition{Grain: seatdef.Whole, Verbs: p.Verbs, Events: p.Events, Defs: p.Defs}
+ tp, err := declareAll(d, nil)
+ if err != nil {
+ return err
+ }
+ partTypes[name] = tp
+ var b bytes.Buffer
+ b.WriteString(tsHeader)
+ fmt.Fprintf(&b, "// The part %q several seats include whole: %s\n\n", p.Part, oneLine(p.Description))
+ tsNames(&b, "verbs", seatdef.VerbNames(p.Verbs))
+ tsNames(&b, "events", seatdef.MessageNames(p.Events))
+ tsTypes(&b, tp)
+ partFiles[name] = b.Bytes()
+ }
+ for _, name := range m.Order {
+ d := m.Seats[name]
+ if d.Grain != seatdef.Whole {
+ continue
+ }
+ dir := TSSeatDir(name)
+ tsPackage(out, dir, TSSeatPackage(m.TSPackage, name), fmt.Sprintf("The contract of the seat %s, generated from its definition.", name))
+ raw := m.Raw[name]
+ fromPart := map[string]string{}
+ for _, inc := range raw.Includes {
+ for _, t := range partTypes[inc].types {
+ fromPart[t.name] = inc
+ }
+ out[dir+"/src/parts/"+inc+".ts"] = partFiles[inc]
+ }
+ tp, err := declareAll(d, fromPart)
+ if err != nil {
+ return err
+ }
+ var b bytes.Buffer
+ b.WriteString(tsHeader)
+ fmt.Fprintf(&b, "// The contract of the seat %q, defined whole (seats/%s/seat.json): %s\n\n", d.Seat, d.Seat, oneLine(d.Description))
+ for _, inc := range raw.Includes {
+ fmt.Fprintf(&b, "import type * as %s from \"./parts/%s.js\";\n\n", tsName(inc), inc)
+ }
+ fmt.Fprintf(&b, "export const seat = %q;\n\n", d.Seat)
+ tsNames(&b, "verbs", seatdef.VerbNames(d.Verbs))
+ tsNames(&b, "accepts", seatdef.MessageNames(d.Accepts))
+ tsNames(&b, "events", seatdef.MessageNames(d.Events))
+ if f := d.Family; f != nil {
+ fmt.Fprintf(&b, "export const family = { name: %q, maxAgeDays: %d", f.Name, f.MaxAgeDays)
+ b.WriteString(" } as const;\n\n")
+ }
+ for _, e := range d.Events {
+ subj := seatdef.EventSubject(d.Seat, e.Name)
+ if e.ByCaller {
+ fmt.Fprintf(&b, "export function %sSubject(caller: string): string {\n return %q + caller;\n}\n\n", tsName(e.Name), subj+".")
+ } else {
+ fmt.Fprintf(&b, "export const %sSubject = %q;\n\n", tsName(e.Name), subj)
+ }
+ }
+ for _, a := range d.Accepts {
+ subj := seatdef.AcceptSubject(d.Seat, a.Name)
+ if a.ByCaller {
+ fmt.Fprintf(&b, "export function %sSubject(caller: string): string {\n return %q + caller;\n}\n\n", tsName(a.Name), subj+".")
+ } else {
+ fmt.Fprintf(&b, "export const %sSubject = %q;\n\n", tsName(a.Name), subj)
+ }
+ }
+ if len(d.HolderEvents) > 0 {
+ tsNames(&b, "holderEvents", holderNames(d.HolderEvents))
+ for _, e := range d.HolderEvents {
+ before, after, _ := strings.Cut(e.Subject, "")
+ fmt.Fprintf(&b, "export function %sHolderSubject(holder: string): string {\n return %q + holder + %q;\n}\n\n",
+ tsName(e.Name), before, after)
+ }
+ }
+ for _, n := range sortedKeys(fromPart) {
+ fmt.Fprintf(&b, "export type %s = %s.%s;\n", n, tsName(fromPart[n]), n)
+ }
+ if len(fromPart) > 0 {
+ b.WriteString("\n")
+ }
+ tsTypes(&b, tp)
+ out[dir+"/src/index.ts"] = b.Bytes()
+ }
+ return nil
+}
+
+func holderNames(es []seatdef.HolderEvent) []string {
+ out := make([]string, len(es))
+ for i, e := range es {
+ out[i] = e.Name
+ }
+ return out
+}
+
+func tsNames(b *bytes.Buffer, what string, names []string) {
+ if len(names) == 0 {
+ return
+ }
+ fmt.Fprintf(b, "export const %s = {\n", what)
+ for _, n := range names {
+ fmt.Fprintf(b, " %s: %q,\n", tsName(n), n)
+ }
+ b.WriteString("} as const;\n\n")
+}
+
+func tsTypes(b *bytes.Buffer, tp *typer) {
+ for _, t := range tp.types {
+ if d := strings.TrimSpace(t.desc); d != "" {
+ fmt.Fprintf(b, "/** %s */\n", oneLine(d))
+ }
+ if t.t.kind != "struct" {
+ fmt.Fprintf(b, "export type %s = %s;\n\n", t.name, tsType(t.t))
+ continue
+ }
+ fmt.Fprintf(b, "export interface %s {\n", t.name)
+ for _, f := range t.t.fields {
+ if f.desc != "" {
+ fmt.Fprintf(b, " /** %s */\n", oneLine(f.desc))
+ }
+ opt := "?"
+ if f.required {
+ opt = ""
+ }
+ fmt.Fprintf(b, " %s%s: %s;\n", strconv.Quote(f.json), opt, tsType(f.t))
+ }
+ b.WriteString("}\n\n")
+ }
+}
+
+func tsType(t *typ) string {
+ switch t.kind {
+ case "string":
+ if len(t.enum) > 0 {
+ parts := make([]string, len(t.enum))
+ for i, e := range t.enum {
+ parts[i] = strconv.Quote(e)
+ }
+ return strings.Join(parts, " | ")
+ }
+ return "string"
+ case "integer", "number":
+ return "number"
+ case "boolean":
+ return "boolean"
+ case "object":
+ return "Record"
+ case "map":
+ return "Record"
+ case "slice":
+ el := tsType(t.elem)
+ if strings.Contains(el, "|") {
+ el = "(" + el + ")"
+ }
+ return el + "[]"
+ case "ref":
+ return t.ref
+ }
+ return "unknown"
+}
+
+// tsSeatList is ts/src/seats.ts: the seat list as data, and every seat's name.
+func tsSeatList(m *Module) []byte {
+ var b bytes.Buffer
+ b.WriteString(tsHeader)
+ b.WriteString(`// The mesh's seat list, generated from every seat's definition (design 26).
+
+export interface Verb {
+ name: string;
+ description: string;
+ input?: Record;
+ output?: Record;
+ optional?: boolean;
+ replaces?: string[];
+}
+
+export interface Seat {
+ name: string;
+ scope: "mesh" | "node";
+ grain: "whole" | "seat-list";
+ decision: string;
+ delivers?: string;
+ replicated?: boolean;
+ accepts?: string[];
+ emits?: string[];
+ pulledByKind?: boolean;
+ yields?: boolean;
+ heldByTheEngine?: boolean;
+ serves?: Verb[];
+}
+
+`)
+ b.WriteString("export const names = {\n")
+ for _, n := range m.Order {
+ fmt.Fprintf(&b, " %s: %q,\n", tsName(n), n)
+ }
+ b.WriteString("} as const;\n\nexport const list: readonly Seat[] = ")
+ var list []map[string]any
+ for _, n := range m.Order {
+ d := m.Seats[n]
+ if d.HolderDeclares {
+ continue
+ }
+ e := map[string]any{"name": d.Seat, "scope": d.Scope, "grain": d.Grain, "decision": d.Decision}
+ if d.Delivers != "" {
+ e["delivers"] = d.Delivers
+ }
+ if d.Replicated {
+ e["replicated"] = true
+ }
+ if len(d.Accepts) > 0 {
+ e["accepts"] = seatdef.MessageNames(d.Accepts)
+ }
+ if len(d.Events) > 0 {
+ e["emits"] = seatdef.MessageNames(d.Events)
+ }
+ if d.PulledByKind {
+ e["pulledByKind"] = true
+ }
+ if d.Yields {
+ e["yields"] = true
+ }
+ if d.HeldByTheEngine {
+ e["heldByTheEngine"] = true
+ }
+ var verbs []map[string]any
+ for _, v := range d.Verbs {
+ vm := map[string]any{"name": v.Name, "description": v.Description}
+ if v.Input != nil {
+ vm["input"] = resolveRefs(v.Input, d.Defs)
+ }
+ if v.Answer != nil {
+ vm["output"] = resolveRefs(v.Answer, d.Defs)
+ }
+ if v.Optional {
+ vm["optional"] = true
+ }
+ if len(v.Replaces) > 0 {
+ vm["replaces"] = v.Replaces
+ }
+ verbs = append(verbs, vm)
+ }
+ if len(verbs) > 0 {
+ e["serves"] = verbs
+ }
+ list = append(list, e)
+ }
+ enc := json.NewEncoder(&b)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+ _ = enc.Encode(list)
+ b.Truncate(b.Len() - 1)
+ b.WriteString(";\n")
+ return b.Bytes()
+}
diff --git a/modules/mesh-build-go/buildstep/seatgen/types.go b/modules/mesh-build-go/buildstep/seatgen/types.go
new file mode 100644
index 00000000..9fa1507c
--- /dev/null
+++ b/modules/mesh-build-go/buildstep/seatgen/types.go
@@ -0,0 +1,185 @@
+package seatgen
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// A typ is one JSON Schema read as a type both languages write: the subset the definitions use (objects with
+// properties, maps, arrays, strings, integers, numbers, booleans, references to a definition's own types). Anything
+// else is `any`, so a schema the generator does not understand still decodes.
+type typ struct {
+ kind string // string, integer, number, boolean, any, map, slice, struct, ref, object (free)
+ elem *typ // a map's value or a slice's item
+ ref string // a referenced type's name
+ fields []field
+ enum []string
+}
+
+type field struct {
+ json string
+ required bool
+ desc string
+ t *typ
+}
+
+// named is one type a seat's package declares: a struct, or an alias of another.
+type named struct {
+ name string
+ desc string
+ t *typ
+}
+
+// typer reads schemas into named types, naming each inline object by where it sits.
+type typer struct {
+ types []named
+ taken map[string]bool
+}
+
+func newTyper() *typer { return &typer{taken: map[string]bool{}} }
+
+// declare reads schema s as the type called name and declares it.
+func (tp *typer) declare(name, desc string, s map[string]any) error {
+ if tp.taken[name] {
+ return fmt.Errorf("two types would be called %s", name)
+ }
+ tp.taken[name] = true
+ t, err := tp.read(name, s, true)
+ if err != nil {
+ return fmt.Errorf("%s: %w", name, err)
+ }
+ if desc == "" {
+ desc, _ = s["description"].(string)
+ }
+ tp.types = append(tp.types, named{name: name, desc: desc, t: t})
+ return nil
+}
+
+func (tp *typer) read(where string, s map[string]any, top bool) (*typ, error) {
+ if s == nil {
+ return &typ{kind: "any"}, nil
+ }
+ if r, ok := s["$ref"].(string); ok {
+ n, found := strings.CutPrefix(r, "#/defs/")
+ if !found || n == "" {
+ return nil, fmt.Errorf("$ref %q is not #/defs/", r)
+ }
+ return &typ{kind: "ref", ref: n}, nil
+ }
+ t, _ := s["type"].(string)
+ switch t {
+ case "string":
+ out := &typ{kind: "string"}
+ if e, ok := s["enum"].([]any); ok {
+ for _, v := range e {
+ if str, ok := v.(string); ok {
+ out.enum = append(out.enum, str)
+ }
+ }
+ }
+ return out, nil
+ case "integer", "number", "boolean":
+ return &typ{kind: t}, nil
+ case "array":
+ items, _ := s["items"].(map[string]any)
+ el, err := tp.inline(where+"Item", items)
+ if err != nil {
+ return nil, err
+ }
+ return &typ{kind: "slice", elem: el}, nil
+ case "object":
+ props, hasProps := s["properties"].(map[string]any)
+ if !hasProps {
+ if ap, ok := s["additionalProperties"].(map[string]any); ok {
+ el, err := tp.inline(where+"Value", ap)
+ if err != nil {
+ return nil, err
+ }
+ return &typ{kind: "map", elem: el}, nil
+ }
+ return &typ{kind: "object"}, nil
+ }
+ required := map[string]bool{}
+ switch r := s["required"].(type) {
+ case []any:
+ for _, v := range r {
+ if str, ok := v.(string); ok {
+ required[str] = true
+ }
+ }
+ case []string:
+ for _, v := range r {
+ required[v] = true
+ }
+ }
+ names := make([]string, 0, len(props))
+ for k := range props {
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ out := &typ{kind: "struct"}
+ for _, k := range names {
+ ps, _ := props[k].(map[string]any)
+ ft, err := tp.inline(where+ident(k), ps)
+ if err != nil {
+ return nil, err
+ }
+ desc, _ := ps["description"].(string)
+ out.fields = append(out.fields, field{json: k, required: required[k], desc: desc, t: ft})
+ }
+ return out, nil
+ }
+ return &typ{kind: "any"}, nil
+}
+
+// inline is a nested schema's type: an object with properties becomes a named type of its own, called where.
+func (tp *typer) inline(where string, s map[string]any) (*typ, error) {
+ if s != nil {
+ if _, props := s["properties"].(map[string]any); props && s["type"] == "object" {
+ desc, _ := s["description"].(string)
+ if err := tp.declare(where, desc, s); err != nil {
+ return nil, err
+ }
+ return &typ{kind: "ref", ref: where}, nil
+ }
+ }
+ return tp.read(where, s, false)
+}
+
+// resolveRefs is a schema with every reference to a definition's own type replaced by that type, for the seat list
+// a reader shows: a caller reading a verb's input there never meets a reference it cannot follow.
+func resolveRefs(s map[string]any, defs map[string]map[string]any) map[string]any {
+ if s == nil {
+ return nil
+ }
+ v := resolveValue(s, defs, 0)
+ m, _ := v.(map[string]any)
+ return m
+}
+
+func resolveValue(v any, defs map[string]map[string]any, depth int) any {
+ if depth > 32 {
+ return v
+ }
+ switch x := v.(type) {
+ case map[string]any:
+ if r, ok := x["$ref"].(string); ok {
+ if d, ok := defs[strings.TrimPrefix(r, "#/defs/")]; ok {
+ return resolveValue(d, defs, depth+1)
+ }
+ }
+ out := make(map[string]any, len(x))
+ for k, e := range x {
+ out[k] = resolveValue(e, defs, depth+1)
+ }
+ return out
+ case []any:
+ out := make([]any, len(x))
+ for i, e := range x {
+ out[i] = resolveValue(e, defs, depth+1)
+ }
+ return out
+ }
+ return v
+}