Files
mesh-catalog/modules/mesh-build-go/buildstep/seatgen/clients.go
T

215 lines
7.6 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, mesh-sdk's runtime and its domain's SDK (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 m.DomainSDK != "" && (path == m.DomainSDK || strings.HasPrefix(path, m.DomainSDK+"/")):
// the domain's SDK (ADR 0634 §2)
case path == RuntimeModule || strings.HasPrefix(path, RuntimeModule+"/"):
case path == ClientGoModule(m.GoModule, seat) || strings.HasPrefix(path, ClientGoModule(m.GoModule, seat)+"/"):
// the client's own packages: a client may be several packages of its module
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
}
// CheckArtifacts is the check of module.json's package artifacts against the tree (ADR 0625 §1, §3), per language.
// 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"))
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": {}, "typescript": {}}
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 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))
}
}
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
}