seatgen: no seat list and no root package, definitions in any folder, seat-list-grain files generate nothing, packages with a dependency-free lock (ADR 0635 §1, ADR 0634 §8, F7; issue 1966)

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-05 13:21:04 +01:00
parent 0beef7ff9c
commit 1abae72457
13 changed files with 164 additions and 487 deletions
+24 -16
View File
@@ -34,8 +34,7 @@ var goModuleLine = regexp.MustCompile(`(?m)^module\s+(\S+)`)
// OutOfStep is every file under root, a path from the module's directory, that the generator writes otherwise than the
// module holds it, for each `generated-from` its package artifacts name, sorted and each once; none for a module naming
// none. The generated Go tree's module path is read from <from>/go/go.mod and the TypeScript package's name from
// <from>/ts/package.json, as the contracts module states them.
// none. The generated trees' names are read from the seats already generated in <from> (generatedNames).
func OutOfStep(moduleDir, root string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if errors.Is(err, os.ErrNotExist) {
@@ -93,23 +92,32 @@ func OutOfStep(moduleDir, root string) ([]string, error) {
return paths, nil
}
// generatedNames reads the names the generated trees are rooted at from the seats already generated in dir: a seat's Go
// module is `<root>/seats/<seat without hyphens>` and its npm package `<root>-<seat>` (a contracts module has no root
// package of either kind, ADR 0635 §1). A module with no seat generated yet gives none, and the generator says so only
// if a seat is defined whole.
func generatedNames(dir string) (goModule, tsPackage string, err error) {
if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootGo, "go.mod")); err == nil {
if m := goModuleLine.FindSubmatch(raw); m != nil {
goModule = string(m[1])
names, _ := filepath.Glob(filepath.Join(dir, "seats", "*", "seat.json"))
for _, p := range names {
seat := filepath.Base(filepath.Dir(p))
pkg := strings.ReplaceAll(seat, "-", "")
if goModule == "" {
if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootGo, "seats", pkg, "go.mod")); err == nil {
if m := goModuleLine.FindSubmatch(raw); m != nil {
goModule = strings.TrimSuffix(string(m[1]), "/seats/"+pkg)
}
}
}
}
if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootTS, "package.json")); err == nil {
var p struct {
Name string `json:"name"`
if tsPackage == "" {
if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootTS, "seats", pkg, "package.json")); err == nil {
var p struct {
Name string `json:"name"`
}
if json.Unmarshal(raw, &p) == nil {
tsPackage = strings.TrimSuffix(p.Name, "-"+seat)
}
}
}
if json.Unmarshal(raw, &p) == nil {
tsPackage = p.Name
}
}
if goModule == "" || tsPackage == "" {
return "", "", errors.New("a contracts module names its generated Go module in go/go.mod and its TypeScript package in ts/package.json; " +
filepath.Base(dir) + " names " + map[bool]string{true: "not the TypeScript package", false: "not the Go module"}[goModule != ""])
}
return goModule, tsPackage, nil
}
@@ -21,8 +21,9 @@ func contracts(t *testing.T, manifest string) (string, []string) {
dir := t.TempDir()
for p, text := range map[string]string{
"module.json": manifest,
"seats/index.json": `{"format":1,"seats":["one-store"]}`,
"seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`,
"seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"whole","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"},"answer":{"type":"object"}}]}`,
// A definition at the seat-list grain is read and generates nothing (ADR 0635 §1, §2).
"seats/other/seat.json": `{"format":1,"seat":"other","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"ask","input":{"type":"object"}}]}`,
} {
writeContract(t, filepath.Join(dir, p), text)
}
@@ -39,13 +40,6 @@ func contracts(t *testing.T, manifest string) (string, []string) {
writeContract(t, filepath.Join(dir, p), string(b))
paths = append(paths, p)
}
// The names the compare reads, where the contracts module states them; a generated go.mod or package.json is kept.
if _, ok := files["go/go.mod"]; !ok {
writeContract(t, filepath.Join(dir, "go/go.mod"), "module example.org/c/go\n")
}
if _, ok := files["ts/package.json"]; !ok {
writeContract(t, filepath.Join(dir, "ts/package.json"), `{"name":"@example/c"}`)
}
return dir, paths
}
@@ -74,6 +68,11 @@ func TestAContractsModulesGeneratedFilesAreComparedWithTheGenerator(t *testing.T
tsFile = p
}
}
for _, p := range paths {
if strings.Contains(p, "other") || p == "go/go.mod" || p == "ts/package.json" {
t.Errorf("%s is generated: a seat-list definition generates nothing and a contracts module has no root package", p)
}
}
if goFile == "" || tsFile == "" {
t.Fatalf("the generator wrote no Go or no TypeScript file: %v", paths)
}
@@ -144,14 +144,10 @@ func checkClient(dir, at, seat string, m *Module) ([]string, error) {
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,
// Go: each seat defined whole is 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: each seat defined whole is the artifact
// `ts-<seat without hyphens>` at TSSeatDir. There is no root package of either kind (ADR 0635 §1). 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"))
@@ -171,10 +167,7 @@ func CheckArtifacts(dir string, m *Module) ([]string, error) {
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"},
}
want := map[string]map[string]string{"go": {}, "typescript": {}}
for name, d := range m.Seats {
if d.Grain == seatdef.Whole {
want["go"]["go-"+pkgName(name)] = "go/seats/" + pkgName(name)
@@ -200,7 +193,7 @@ func CheckArtifacts(dir string, m *Module) ([]string, error) {
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")
out = append(out, "module.json: the "+said[a.Language]+" package artifact "+a.Name+" is no seat's module and no client'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))
}
@@ -29,12 +29,10 @@ import (
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"]},
const 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"]},
return `{"build":{"artifacts":[{"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"]}`
@@ -55,10 +53,8 @@ func Publish(ctx context.Context) string { _ = parts.MaxChunk; return "seat:" +
}, 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"]},
`{"build":{"artifacts":[{"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]",
@@ -15,15 +15,10 @@ import (
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
// Each seat defined whole is a Go module of its own, 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. There is no seat list and no root Go module (ADR 0635 §1): the list is read
// from the bus.
for _, name := range m.Order {
d := m.Seats[name]
if d.Grain != seatdef.Whole {
@@ -65,254 +60,6 @@ func gofmt(b *bytes.Buffer) ([]byte, error) {
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) {
@@ -11,9 +11,10 @@ import (
"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.
// TestEachSeatDefinedWholeIsAGoModuleOfItsOwn is ADR 0625's generator check (§1, §2, §3): there is no seat set and no
// root Go module (ADR 0635 §1), each seat defined whole is a Go module of its own holding its package alone, the
// parts it includes are written into it, a definition at the seat-list grain generates nothing, 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 {
@@ -30,7 +31,6 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
}
}
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",
}
@@ -54,8 +54,8 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
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)
if mods[dir] == "" {
t.Errorf("%s is in no seat's own Go module", p)
}
f, err := parser.ParseFile(token.NewFileSet(), p, b, parser.ImportsOnly)
if err != nil {
@@ -77,13 +77,15 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
t.Errorf("%s does not name the part's enum values:\n%s", seat, src)
}
}
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")
for p := range files {
if strings.Contains(p, "onestore") || p == "go/seats/seats.go" || p == "go/go.mod" || p == "ts/package.json" || p == "ts/src/seats.ts" {
t.Errorf("%s is written: a seat list is not generated, and a definition at its grain generates nothing", p)
}
}
// 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.
// importing nothing outside itself; there is no package above them.
pkgs := map[string]string{}
for p, b := range files {
if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, "package.json") {
@@ -97,7 +99,6 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
}
}
wantPkgs := map[string]string{
"ts/": "@example/contracts",
"ts/seats/aregistry/": "@example/contracts-a-registry",
"ts/seats/bregistry/": "@example/contracts-b-registry",
}
@@ -132,13 +133,29 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
}
}
}
// F7: a published package is built from its own lock (mesh-build-node refuses one with none), and a generated one has
// no dependency to lock: its lock names the package alone and its package.json has no script and no dependency.
for _, seat := range []string{"aregistry", "bregistry"} {
var pj, lock map[string]any
if err := json.Unmarshal(files["ts/seats/"+seat+"/package.json"], &pj); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(files["ts/seats/"+seat+"/package-lock.json"], &lock); err != nil {
t.Fatalf("%s has no readable package-lock.json: %v", seat, err)
}
for _, k := range []string{"scripts", "dependencies", "devDependencies"} {
if _, ok := pj[k]; ok {
t.Errorf("%s's package.json has %s: a generated package needs no compiler or dependency", seat, k)
}
}
if pk, _ := lock["packages"].(map[string]any); len(pk) != 1 || lock["name"] != pj["name"] {
t.Errorf("%s's lock %v does not name its package alone", seat, lock)
}
}
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)
}
}
+12 -24
View File
@@ -21,7 +21,7 @@ type Module struct {
GoModule string
// TSPackage is the npm package's name.
TSPackage string
// Order is the seat list's reading order (seats/index.json).
// Order is every seat's name, sorted: a contracts module has no seat list and no reading order (ADR 0635 §1).
Order []string
// Seats are every definition, resolved, by name.
Seats map[string]seatdef.Definition
@@ -33,20 +33,13 @@ type Module struct {
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.
// Load reads a contracts module from its directory, wherever that is in its repository (ADR 0634 §8):
// `seats/<seat>/seat.json`, `seats/<seat>/fixtures/*.json` and `parts/*.json`. A definition at the seat-list grain is
// read and checked like the rest and generates nothing. goModule and tsPackage name the generated trees' roots; a
// module with no seat defined whole needs neither.
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 {
@@ -64,15 +57,8 @@ func Load(dir, goModule, tsPackage string) (*Module, error) {
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
@@ -85,6 +71,7 @@ func Load(dir, goModule, tsPackage string) (*Module, error) {
return nil, fmt.Errorf("%s: names seat %q", rel(dir, p), d.Seat)
}
m.Raw[name] = d
m.Order = append(m.Order, name)
resolved, err := seatdef.Resolve(d, m.Parts)
if err != nil {
return nil, fmt.Errorf("%s: %w", rel(dir, p), err)
@@ -104,11 +91,7 @@ func Load(dir, goModule, tsPackage string) (*Module, error) {
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)
}
}
sort.Strings(m.Order)
return m, nil
}
@@ -121,6 +104,11 @@ func rel(dir, p string) string {
// 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) {
for _, name := range m.Order {
if m.Seats[name].Grain == seatdef.Whole && (m.GoModule == "" || m.TSPackage == "") {
return nil, fmt.Errorf("the seat %s is defined whole and the module names no generated Go module or TypeScript package yet: give them to the generator (seatgen -go-module, -ts-package)", name)
}
}
out := map[string][]byte{}
if err := goFiles(m, out); err != nil {
return nil, err
@@ -24,7 +24,7 @@ func TestAStateHasItsNamesKeyFormsAndStrictValues(t *testing.T) {
}
src := string(files["go/seats/aregistry/contract.go"])
for _, want := range []string{
"func AsksByAskerIDKey(asker, id string) string", "func ParseAsksByAskerIDKey(key string) (string, string, bool)",
"func AsksByAskerIDKey(asker, id string) string", "func ParseAsksByAskerIDKey(key string) (string, string, bool)",
"func DecodeAsksByAskerIDValue(raw []byte)", "type AsksByAskerIDValue struct",
"func AsksByAskerIDDoneKey(", "func AsksByDefaultsKey() string",
"func LocalNameBySeenIDOfAskKey(idOfAsk string) string",
@@ -1 +0,0 @@
{"format":1,"seats":["one-store","a-registry","b-registry"]}
+16 -109
View File
@@ -19,14 +19,15 @@ 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 }
// tsConfig type-checks the package's sources and writes nothing: the package ships its TypeScript, which its importers
// compile or bundle, so it has no build script and needs no compiler of its own (F7: a generated package has no
// dependency, and its lock, which the build module requires of a package artifact, holds none).
const tsConfig = `{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
@@ -35,27 +36,28 @@ const tsConfig = `{
}
`
// tsPackage writes one npm package's package.json and tsconfig.json at dir.
// tsPackage writes one npm package's package.json, its dependency-free package-lock.json and its 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"},
"description": description,
"exports": map[string]any{".": "./src/index.ts"},
"types": "./src/index.ts",
"files": []any{"src"},
}
raw, _ := json.MarshalIndent(pkg, "", " ")
out[dir+"/package.json"] = append(raw, '\n')
lock := map[string]any{
"name": name, "version": "0.1.0", "lockfileVersion": 3, "requires": true,
"packages": map[string]any{"": map[string]any{"name": name, "version": "0.1.0"}},
}
raw, _ = json.MarshalIndent(lock, "", " ")
out[dir+"/package-lock.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.
// tsFiles writes the npm side: 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) {
@@ -221,98 +223,3 @@ func tsType(t *typ) string {
}
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()
}
@@ -146,40 +146,3 @@ func (tp *typer) inline(where string, s map[string]any) (*typ, error) {
}
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
}
@@ -85,7 +85,6 @@ func TestAPackageWithNeitherScriptNorTypeScriptHasNoTests(t *testing.T) {
m, _, work = newMachine(t)
workspaceWith(t, work, map[string]string{
"module.json": `{"module":"c","build":{"artifacts":[{"name":"ts","kind":"package","language":"typescript","paths":["ts"],"generated-from":"."}]}}`,
"seats/index.json": `{"format":1,"seats":["one-store"]}`,
"seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`,
"go/go.mod": "module example.org/c/go\n", "go/stray.go": "package stray\n",
"ts/package.json": `{"name":"@example/c"}`, "ts/stray.ts.txt": "not written by the generator\n",
@@ -0,0 +1,61 @@
package main
import (
"context"
"path/filepath"
"slices"
"testing"
"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/seatgen"
)
// F7 (ADR 0625 §5, ADR 0634 §8): a package the seat generator wrote, with its dependency-free lock and no script, is
// installed from that lock (npm ci) and packed (npm pack) with no build script run, as any package artifact is.
func TestAGeneratedSeatPackageIsInstalledFromItsLockAndPackedWithoutABuild(t *testing.T) {
m, f, work := newMachine(t)
seat := `{"format":1,"seat":"one-store","scope":"mesh","grain":"whole","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"},"answer":{"type":"object"}}]}`
src := t.TempDir()
write(t, filepath.Join(src, "seats", "one-store", "seat.json"), seat, 0o644)
g, err := seatgen.Load(src, "example.org/c/go", "@example/c")
if err != nil {
t.Fatal(err)
}
files, err := seatgen.Generate(g)
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(work, "w1", "tree", "ts", "seats", "onestore")
for _, name := range []string{"package.json", "package-lock.json"} {
body, ok := files["ts/seats/onestore/"+name]
if !ok {
t.Fatalf("the generator wrote no %s for the seat's package", name)
}
write(t, filepath.Join(dir, name), string(body), 0o644)
}
f.effect = func(c call) {
if c.name == "npm" && len(c.args) > 0 && c.args[0] == "pack" {
dest := c.args[slices.Index(c.args, "--pack-destination")+1]
write(t, filepath.Join(dest, "example-c-one-store-0.1.0.tgz"), "tgz", 0o644)
}
}
ctx := context.Background()
if err := m.installDev(ctx, work, dir, "ts/seats/onestore"); err != nil {
t.Fatal(err)
}
p, err := m.buildAndPack(ctx, work, dir, "ts/seats/onestore", false, "")
if err != nil {
t.Fatal(err)
}
if p.Name != "@example/c-one-store" || p.Version != "0.1.0" {
t.Errorf("packed %s@%s", p.Name, p.Version)
}
var ran []string
for _, c := range f.calls {
if c.name == "npm" {
ran = append(ran, c.args[0])
}
}
if !slices.Equal(ran, []string{"ci", "pack"}) {
t.Errorf("npm ran %v, want ci then pack and no build script", ran)
}
}