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