Files
mesh-catalog/modules/mesh-build-go/buildstep/seatgen/clients.go
T
jschoubben 7b08f25d4a CAT-BG4/BN1: the seat generator moves into buildstep, one program for Go and TypeScript
Its version is the build module's own, so a new generator is a new build of
mesh-build-go and mesh-build-node and a new fingerprint of what they compare;
no repository of its own. Moved from seatgen c1fdd7c unchanged but for the
import path.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:12:46 +01:00

218 lines
7.4 KiB
Go

package seatgen
import (
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
re "regexp"
"sort"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// ClientRoot is where a seat's hand-written client packages live (ADR 0625 §4, §5; ADR 0627): one directory per
// client, `go/clients/<seat without hyphens>client`, a Go module of its own (ADR 0625 §3), so the seat set's importers
// never import a client and a client's import of mesh-sdk forms no cycle. Compare leaves the tree to CheckClients.
const ClientRoot = "go/clients"
// RuntimeModule is mesh-sdk's Go module: the one module beside its own seat's contract a client may import (ADR 0625
// §3, §4).
const RuntimeModule = "git.novox.be/novox/mesh-sdk/go"
// ClientGoModule is the Go module of a seat's client, beside its contract's.
func ClientGoModule(root, seat string) string { return root + "/clients/" + pkgName(seat) + "client" }
// a module's tool address (`<module>.<tool_name>`) or call (`<module>.<tool>` written with an underscore).
var toolAddress = re.MustCompile(`^[a-z][a-z0-9-]*\.[a-z0-9]+_[a-z0-9_]+$`)
// CheckClients is ADR 0627 §3's catalog check of the clients, and ADR 0625 §5's rule for the files beside them: every
// directory under ClientRoot is the client of one seat defined whole, a Go module of its own at ClientGoModule; it
// imports only the standard library, its own seat's contract module and mesh-sdk's runtime (never another seat's
// contract or client); and no string in it names a module's subject (`mesh.mod.`), a module's tool, or another seat
// (its name, its subjects, its `seat:` call). Each problem is named once, sorted.
func CheckClients(dir string, m *Module) ([]string, error) {
var out []string
entries, err := os.ReadDir(filepath.Join(dir, ClientRoot))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
bySeatPkg := map[string]string{}
for name, d := range m.Seats {
if d.Grain == seatdef.Whole {
bySeatPkg[pkgName(name)+"client"] = name
}
}
for _, e := range entries {
at := ClientRoot + "/" + e.Name()
if !e.IsDir() {
out = append(out, at+": a file under the clients' root, in no client")
continue
}
seat, ok := bySeatPkg[e.Name()]
if !ok {
out = append(out, at+": the client of no seat defined whole (a client is `<seat without hyphens>client`)")
continue
}
mod, err := os.ReadFile(filepath.Join(dir, at, "go.mod"))
want := "module " + ClientGoModule(m.GoModule, seat)
if err != nil || strings.SplitN(string(mod), "\n", 2)[0] != want {
out = append(out, at+"/go.mod: not "+strconv.Quote(want)+": a client is a Go module of its own (ADR 0625 §3)")
}
problems, err := checkClient(dir, at, seat, m)
if err != nil {
return nil, err
}
out = append(out, problems...)
}
sort.Strings(out)
return out, nil
}
func checkClient(dir, at, seat string, m *Module) ([]string, error) {
var out []string
own := SeatGoModule(m.GoModule, seat)
others := map[string]bool{}
for name := range m.Seats {
if name != seat {
others[name] = true
}
}
namesOther := func(s string) bool {
if others[s] {
return true
}
for name := range others {
if strings.HasPrefix(s, "mesh.seat."+name+".") || strings.HasPrefix(s, "seat:"+name+".") {
return true
}
}
return false
}
err := filepath.WalkDir(filepath.Join(dir, at), func(p string, e os.DirEntry, err error) error {
if err != nil || e.IsDir() || !strings.HasSuffix(p, ".go") {
return err
}
r := rel(dir, p)
f, err := parser.ParseFile(token.NewFileSet(), p, nil, parser.SkipObjectResolution)
if err != nil {
return err
}
for _, im := range f.Imports {
path, _ := strconv.Unquote(im.Path.Value)
switch {
case !strings.Contains(strings.SplitN(path, "/", 2)[0], "."):
case path == own:
case path == RuntimeModule || strings.HasPrefix(path, RuntimeModule+"/"):
case strings.HasPrefix(path, m.GoModule+"/clients/"):
out = append(out, r+": imports "+path+", another seat's client (ADR 0625 §4)")
case path == m.GoModule || strings.HasPrefix(path, m.GoModule+"/"):
out = append(out, r+": imports "+path+", not its own seat's contract (ADR 0625 §4)")
default:
out = append(out, r+": imports "+path+": a client uses only its seat's contract and mesh-sdk's runtime (ADR 0625 §3)")
}
}
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
switch {
case strings.HasPrefix(v, "mesh.mod."):
out = append(out, fmt.Sprintf("%s: names a module's subject %q (ADR 0625 §4)", r, v))
case toolAddress.MatchString(v):
out = append(out, fmt.Sprintf("%s: names a module's tool %q (ADR 0625 §4)", r, v))
case namesOther(v):
out = append(out, fmt.Sprintf("%s: names another seat %q (ADR 0625 §4)", r, v))
}
return true
})
return nil
})
return out, err
}
// TSSetArtifact is the seat set's npm package artifact, at ts/.
const TSSetArtifact = "lib"
// CheckArtifacts is the check of module.json's package artifacts against the tree (ADR 0625 §1, §3), per language.
// Go: the root module is the artifact `go` at `go`, each seat defined whole the artifact `go-<seat without hyphens>`
// at its module's directory, each client present the artifact `go-<seat without hyphens>client` at its directory.
// npm: the seat set's package is the artifact `lib` at `ts`, each seat defined whole the artifact
// `ts-<seat without hyphens>` at TSSeatDir. No other package artifact of either language. Each problem is named once,
// sorted.
func CheckArtifacts(dir string, m *Module) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
if err != nil {
return nil, err
}
var mf struct {
Build struct {
Artifacts []struct {
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
Paths []string `json:"paths"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &mf); err != nil {
return nil, fmt.Errorf("module.json: %w", err)
}
want := map[string]map[string]string{
"go": {"go": "go"},
"typescript": {TSSetArtifact: "ts"},
}
for name, d := range m.Seats {
if d.Grain == seatdef.Whole {
want["go"]["go-"+pkgName(name)] = "go/seats/" + pkgName(name)
want["typescript"]["ts-"+pkgName(name)] = TSSeatDir(name)
}
}
if entries, err := os.ReadDir(filepath.Join(dir, ClientRoot)); err == nil {
for _, e := range entries {
if e.IsDir() {
want["go"]["go-"+e.Name()] = ClientRoot + "/" + e.Name()
}
}
}
said := map[string]string{"go": "Go", "typescript": "npm"}
var out []string
seen := map[string]bool{}
for _, a := range mf.Build.Artifacts {
paths, lang := want[a.Language]
if a.Kind != "package" || !lang {
continue
}
seen[a.Language+"/"+a.Name] = true
path, ok := paths[a.Name]
switch {
case !ok:
out = append(out, "module.json: the "+said[a.Language]+" package artifact "+a.Name+" is no seat's module, no client's and not the seat set's")
case len(a.Paths) != 1 || a.Paths[0] != path:
out = append(out, fmt.Sprintf("module.json: the artifact %s's paths are %v, not [%s]", a.Name, a.Paths, path))
}
}
for lang, paths := range want {
for name, path := range paths {
if !seen[lang+"/"+name] {
out = append(out, "module.json: no "+said[lang]+" package artifact "+name+" for "+path)
}
}
}
sort.Strings(out)
return out, nil
}