From 1abae724577293313e48880bb5d1343d749b6100 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 13:21:04 +0100 Subject: [PATCH] =?UTF-8?q?seatgen:=20no=20seat=20list=20and=20no=20root?= =?UTF-8?q?=20package,=20definitions=20in=20any=20folder,=20seat-list-grai?= =?UTF-8?q?n=20files=20generate=20nothing,=20packages=20with=20a=20depende?= =?UTF-8?q?ncy-free=20lock=20(ADR=200635=20=C2=A71,=20ADR=200634=20=C2=A78?= =?UTF-8?q?,=20F7;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/mesh-build-go/buildstep/generated.go | 40 +-- .../mesh-build-go/buildstep/generated_test.go | 17 +- .../buildstep/seatgen/clients.go | 17 +- .../buildstep/seatgen/clients_test.go | 10 +- .../mesh-build-go/buildstep/seatgen/gogen.go | 261 +----------------- .../buildstep/seatgen/layout_test.go | 43 ++- .../mesh-build-go/buildstep/seatgen/load.go | 36 +-- .../buildstep/seatgen/states_test.go | 2 +- .../testdata/contracts/seats/index.json | 1 - .../mesh-build-go/buildstep/seatgen/tsgen.go | 125 ++------- .../mesh-build-go/buildstep/seatgen/types.go | 37 --- .../cmd/mesh-build-node/check_test.go | 1 - .../mesh-build-node/generated_package_test.go | 61 ++++ 13 files changed, 164 insertions(+), 487 deletions(-) delete mode 100644 modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/index.json create mode 100644 modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go diff --git a/modules/mesh-build-go/buildstep/generated.go b/modules/mesh-build-go/buildstep/generated.go index c164951de..2130550e8 100644 --- a/modules/mesh-build-go/buildstep/generated.go +++ b/modules/mesh-build-go/buildstep/generated.go @@ -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 /go/go.mod and the TypeScript package's name from -// /ts/package.json, as the contracts module states them. +// none. The generated trees' names are read from the seats already generated in (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 `/seats/` and its npm package `-` (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 } diff --git a/modules/mesh-build-go/buildstep/generated_test.go b/modules/mesh-build-go/buildstep/generated_test.go index bce50b660..eb4830260 100644 --- a/modules/mesh-build-go/buildstep/generated_test.go +++ b/modules/mesh-build-go/buildstep/generated_test.go @@ -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) } diff --git a/modules/mesh-build-go/buildstep/seatgen/clients.go b/modules/mesh-build-go/buildstep/seatgen/clients.go index 13e85ac30..db370e2fc 100644 --- a/modules/mesh-build-go/buildstep/seatgen/clients.go +++ b/modules/mesh-build-go/buildstep/seatgen/clients.go @@ -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-` -// 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, +// Go: each seat defined whole is the artifact `go-` at its module's directory, each client +// present the artifact `go-client` at its directory. npm: each seat defined whole is the artifact +// `ts-` 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)) } diff --git a/modules/mesh-build-go/buildstep/seatgen/clients_test.go b/modules/mesh-build-go/buildstep/seatgen/clients_test.go index 9a9795896..b3999a2fd 100644 --- a/modules/mesh-build-go/buildstep/seatgen/clients_test.go +++ b/modules/mesh-build-go/buildstep/seatgen/clients_test.go @@ -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]", diff --git a/modules/mesh-build-go/buildstep/seatgen/gogen.go b/modules/mesh-build-go/buildstep/seatgen/gogen.go index c7a5503d1..0ef1a0cf5 100644 --- a/modules/mesh-build-go/buildstep/seatgen/gogen.go +++ b/modules/mesh-build-go/buildstep/seatgen/gogen.go @@ -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) { diff --git a/modules/mesh-build-go/buildstep/seatgen/layout_test.go b/modules/mesh-build-go/buildstep/seatgen/layout_test.go index c7848ab63..d95b976df 100644 --- a/modules/mesh-build-go/buildstep/seatgen/layout_test.go +++ b/modules/mesh-build-go/buildstep/seatgen/layout_test.go @@ -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 (`-` at ts/seats/), 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) - } } diff --git a/modules/mesh-build-go/buildstep/seatgen/load.go b/modules/mesh-build-go/buildstep/seatgen/load.go index 43eb6548f..572ef8018 100644 --- a/modules/mesh-build-go/buildstep/seatgen/load.go +++ b/modules/mesh-build-go/buildstep/seatgen/load.go @@ -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.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. +// Load reads a contracts module from its directory, wherever that is in its repository (ADR 0634 §8): +// `seats//seat.json`, `seats//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 diff --git a/modules/mesh-build-go/buildstep/seatgen/states_test.go b/modules/mesh-build-go/buildstep/seatgen/states_test.go index cb6aed284..61ed3fc0d 100644 --- a/modules/mesh-build-go/buildstep/seatgen/states_test.go +++ b/modules/mesh-build-go/buildstep/seatgen/states_test.go @@ -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", 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 deleted file mode 100644 index 5218cd345..000000000 --- a/modules/mesh-build-go/buildstep/seatgen/testdata/contracts/seats/index.json +++ /dev/null @@ -1 +0,0 @@ -{"format":1,"seats":["one-store","a-registry","b-registry"]} diff --git a/modules/mesh-build-go/buildstep/seatgen/tsgen.go b/modules/mesh-build-go/buildstep/seatgen/tsgen.go index dc5e71fcf..bd4855301 100644 --- a/modules/mesh-build-go/buildstep/seatgen/tsgen.go +++ b/modules/mesh-build-go/buildstep/seatgen/tsgen.go @@ -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; - 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 index 9fa1507c2..7d0afa94b 100644 --- a/modules/mesh-build-go/buildstep/seatgen/types.go +++ b/modules/mesh-build-go/buildstep/seatgen/types.go @@ -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 -} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go index 54b2e5336..1238f950c 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/check_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/check_test.go @@ -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", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go b/modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go new file mode 100644 index 000000000..1a3a3152c --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/generated_package_test.go @@ -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) + } +}