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:
@@ -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,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
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
+3
@@ -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"]}}}
|
||||
+3
@@ -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}}
|
||||
+3
@@ -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"]}
|
||||
+1
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user