CAT-BG4/BN1: the seat generator moves into buildstep, one program for Go and TypeScript

Its version is the build module's own, so a new generator is a new build of
mesh-build-go and mesh-build-node and a new fingerprint of what they compare;
no repository of its own. Moved from seatgen c1fdd7c unchanged but for the
import path.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 15:12:46 +01:00
parent b5495d0cc8
commit 7b08f25d4a
17 changed files with 1980 additions and 4 deletions
+5
View File
@@ -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 <dir>`; `seatgen -check <dir>` 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@<version> -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
@@ -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")
}
+2 -2
View File
@@ -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.
@@ -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
-1
View File
@@ -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
)
@@ -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/<seat without hyphens>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 (`<module>.<tool_name>`) or call (`<module>.<tool>` 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 `<seat without hyphens>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-<seat without hyphens>`
// at its module's directory, each client present the artifact `go-<seat without hyphens>client` at its directory.
// npm: the seat set's package is the artifact `lib` at `ts`, each seat defined whole the artifact
// `ts-<seat without hyphens>` 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
}
@@ -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)
}
}
@@ -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, "<holder>")
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, "<holder>")
p := "^" + regexp(before)
if strings.Contains(e.Subject, "<holder>") {
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()
}
@@ -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 (`<package>-<seat>` at ts/seats/<seat without hyphens>), 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)
}
}
@@ -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 (`<module>/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>/seat.json`, `seats/<seat>/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:]
}
@@ -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"]}}}
@@ -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}}
@@ -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}}
@@ -0,0 +1 @@
{"format":1,"seats":["one-store","a-registry","b-registry"]}
@@ -0,0 +1 @@
{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}
@@ -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, "<holder>")
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<string, unknown>"
case "map":
return "Record<string, " + tsType(t.elem) + ">"
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<string, unknown>;
output?: Record<string, unknown>;
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()
}
@@ -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/<name>", 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
}