Files
mesh-delivery/modules/mesh-delivery-checker/rules/seats.go
T
2026-11-12 06:27:42 +01:00

344 lines
12 KiB
Go

package rules
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"maps"
"path"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/seatdefs"
)
// The seat list a check judges and composes against (novox/hq ADR 0636 §1, §4, ADR 0644 §1; the lead's rulings Q2 on
// milestones 47/B0 and 47/G): the checking program built from the heads reads the definitions the heads' contracts
// modules carry, by the domain SDK's one reading (sdk/seatdefs), the indexer's too, and answers them; the running checker
// lays them by seat over the list its reader holds from the seat mesh-catalog (verb seat-definitions) and reads no file
// of the heads. The reader's list is not in the program's input: it is large, and the answer carries only what the heads
// change.
// SeatsVersion is the version of SeatsAnswer this program writes.
const SeatsVersion = 1
// SeatsAnswer is the checking program's `-seats` answer: every seat definition the heads carry, resolved, as the requests'
// field `seats` carries them, sorted by seat, that is new or differs from the reader's (Facts.SeatDigests); how many
// were the same and left out of it (Same); and each definition left out, with why.
type SeatsAnswer struct {
V int `json:"v"`
Seats []json.RawMessage `json:"seats"`
Same int `json:"same,omitempty"`
Left []LeftOut `json:"left,omitempty"`
}
// SeatDigest is a definition's digest, the same for the same definition whatever its bytes' order and spacing: the sha256
// of its JSON with every object's keys sorted, numbers as written. Bytes that are not JSON are digested as given.
func SeatDigest(raw json.RawMessage) string {
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
var v any
canonical := []byte(raw)
if d.Decode(&v) == nil {
if out, err := json.Marshal(v); err == nil {
canonical = out
}
}
sum := sha256.Sum256(canonical)
return hex.EncodeToString(sum[:])
}
// LeftOut is a seat definition the list does not take from the heads: its file and why.
type LeftOut struct {
Path string `json:"path"`
Why string `json:"why"`
}
// SeatsOf is the seat definitions of the facts' members' heads that are new or differ from the reader's (Facts.SeatDigests). heads holds each member's head at its repository's path; a contracts module is a manifest there whose package artifact names
// `generated-from` (seatdefs.Of). A seat of the mesh's own a module that is not its domain's defines (seatdefs.Refusal),
// and a seat two modules define where neither hands it over, are left out, said; a definition that does not parse is an
// error.
// Heads with no contracts module give an empty list.
func SeatsOf(heads fs.FS, f Facts) (SeatsAnswer, error) {
out := SeatsAnswer{V: SeatsVersion}
type taken struct {
raw json.RawMessage
module, file string
}
by := map[string]taken{}
refused := map[string]bool{}
repositories := map[string]string{}
for _, m := range f.Members {
repositories[m.Repository] = m.Head
}
for _, repository := range slices.Sorted(maps.Keys(repositories)) {
if _, err := fs.Stat(heads, repository); errors.Is(err, fs.ErrNotExist) {
continue // no head of it is laid: it carries no definition here
}
sub, err := fs.Sub(heads, repository)
if err != nil {
return SeatsAnswer{}, err
}
manifests, err := manifestsIn(sub)
if err != nil {
return SeatsAnswer{}, err
}
forge := localForge(sub)
for _, c := range seatdefs.ContractsModules(manifests) {
defs, unparsed, err := forge.Read(context.Background(), repository, repositories[repository], c)
if err != nil {
return SeatsAnswer{}, fmt.Errorf("the seat definitions of %s at the head of %s cannot be read: %w", c.Module, repository, err)
}
// A head's definition that does not parse, or names another seat than its directory's, leaves the group's seats
// unknown: no list is made, so no manifest is judged by a list without it (design 26).
for _, seat := range slices.Sorted(maps.Keys(unparsed)) {
return SeatsAnswer{}, fmt.Errorf("the seat definition of %s (module %s) at the head of %s is not taken: %s", seat, c.Module, repository, unparsed[seat])
}
for _, seat := range slices.Sorted(maps.Keys(defs)) {
d, file := defs[seat], defFile(repository, c, seat)
if why := seatdefs.Refusal(c.Module, d); why != "" {
out.Left = append(out.Left, LeftOut{Path: file, Why: why})
continue
}
if prev, twice := by[seat]; twice || refused[seat] {
switch {
case refused[seat]:
out.Left = append(out.Left, LeftOut{Path: file, Why: "the seat " + seat + " is defined in more than one place"})
continue
case seatdefs.HandsOver(prev.module, c.Module, d):
case seatdefs.HandsOver(c.Module, prev.module, d):
continue
default:
why := "the seat " + seat + " is defined in more than one place: " + prev.file + ", " + file
out.Left = append(out.Left, LeftOut{Path: prev.file, Why: why}, LeftOut{Path: file, Why: why})
delete(by, seat)
refused[seat] = true
continue
}
}
raw, err := json.Marshal(d)
if err != nil {
out.Left = append(out.Left, LeftOut{Path: file, Why: err.Error()})
continue
}
by[seat] = taken{raw: raw, module: c.Module, file: file}
}
}
}
out.Seats = make([]json.RawMessage, 0, len(by))
for _, seat := range slices.Sorted(maps.Keys(by)) {
if held, ok := f.SeatDigests[seat]; ok && held == SeatDigest(by[seat].raw) {
out.Same++ // the reader holds it as the heads carry it: the checker's own list keeps it
continue
}
raw := by[seat].raw
// The answer is the judging view of a seat, what the manifest validator and the composer read (the lead's ruling after
// the group check of 616c42434ac2), with digests or without: no verb's or message's schema, which neither reads, and
// which the build seat's step state, keeping this answer, would refuse where a property is named like a credential
// (mesh-vault's verb input `credentials`).
raw = withoutMessageSchemas(raw)
if f.SeatDigests == nil {
// A checker from before G hands no digests, so every definition is answered: whole they are over a run answer's
// bound, so every description but the seat's own is left out too, which decides no verdict; review round 2 of G.
raw = withoutInnerDescriptions(raw)
}
out.Seats = append(out.Seats, raw)
}
return out, nil
}
// defFile is a seat's definition file in a head, by its repository's path.
func defFile(repository string, c seatdefs.Module, seat string) string {
return path.Join(repository, c.Defs, "seats", seat, "seat.json")
}
// manifestsIn are the raw manifests of a head by their directory (the root's is "."), the generated contracts, the tests'
// data and the packages' dependencies left out.
func manifestsIn(head fs.FS) (map[string]string, error) {
out := map[string]string{}
err := fs.WalkDir(head, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && (d.Name() == ".git" || d.Name() == "testdata" || d.Name() == "node_modules") {
return fs.SkipDir
}
if !d.IsDir() && d.Name() == "module.json" {
raw, err := fs.ReadFile(head, p)
if err != nil {
return err
}
out[path.Dir(p)] = string(raw)
}
return nil
})
return out, err
}
// localForge is the domain SDK's reading bound to a head laid on disk: a directory or a file the head does not hold is
// none.
func localForge(head fs.FS) seatdefs.Forge {
return seatdefs.Forge{
Files: func(_ context.Context, _, _ string, dirs []string) (map[string]map[string]string, error) {
out := map[string]map[string]string{}
for _, dir := range dirs {
files := map[string]string{}
err := fs.WalkDir(head, dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
files[strings.TrimPrefix(p, dir+"/")] = ""
}
return nil
})
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
out[dir] = files
}
return out, nil
},
File: func(_ context.Context, _, _, file string) ([]byte, error) {
raw, err := fs.ReadFile(head, file)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return raw, err
},
}
}
// withoutMessageSchemas is a definition whose verbs' input and answer, and whose accepted messages', events' and holder
// events' bodies, are each the empty schema, and whose types (`defs`) are only those something it keeps still refers to
// (a state's key forms, a provision's form, another kept type). Every other field is kept as written; bytes that are not
// a JSON object are given back as they are.
func withoutMessageSchemas(raw json.RawMessage) json.RawMessage {
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
var def map[string]any
if d.Decode(&def) != nil {
return raw
}
empty := func(list any, fields ...string) {
items, _ := list.([]any)
for _, it := range items {
m, ok := it.(map[string]any)
if !ok {
continue
}
for _, f := range fields {
if _, has := m[f]; has {
m[f] = map[string]any{}
}
}
}
}
empty(def["verbs"], "input", "answer")
for _, field := range []string{"accepts", "events", "holder-events"} {
empty(def[field], "body")
}
if defs, ok := def["defs"].(map[string]any); ok {
rest := maps.Clone(def)
delete(rest, "defs")
kept := map[string]bool{}
todo := typesReferred(rest)
for len(todo) > 0 {
name := todo[len(todo)-1]
todo = todo[:len(todo)-1]
if kept[name] || defs[name] == nil {
continue
}
kept[name] = true
todo = append(todo, typesReferred(defs[name])...)
}
maps.DeleteFunc(defs, func(name string, _ any) bool { return !kept[name] })
if len(defs) == 0 {
delete(def, "defs")
}
}
out, err := json.Marshal(def)
if err != nil {
return raw
}
return out
}
// typesReferred are the names of the definition's types (`{"$ref": "#/defs/<name>"}`) v refers to, anywhere in it.
func typesReferred(v any) []string {
var out []string
var walk func(any)
walk = func(v any) {
switch t := v.(type) {
case map[string]any:
if ref, ok := t["$ref"].(string); ok {
if name, found := strings.CutPrefix(ref, "#/defs/"); found {
out = append(out, name)
}
}
for _, x := range t {
walk(x)
}
case []any:
for _, x := range t {
walk(x)
}
}
}
walk(v)
return out
}
// schemaData are the keywords of a schema whose value is data, never a schema: what they hold is kept as written.
var schemaData = map[string]bool{"examples": true, "enum": true, "const": true, "default": true}
// withoutInnerDescriptions is a definition with only the seat's own description kept: every other `description` string,
// of a verb, an event or anything inside their schemas, is left out, every other field kept (a property named
// description is a schema, kept; a schema's data keywords are kept whole). Bytes that are not a JSON object are given
// back as they are.
func withoutInnerDescriptions(raw json.RawMessage) json.RawMessage {
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
var def map[string]any
if d.Decode(&def) != nil {
return raw
}
var walk func(v any)
walk = func(v any) {
switch t := v.(type) {
case map[string]any:
for k, val := range t {
if _, prose := val.(string); prose && k == "description" {
delete(t, k)
continue
}
if !schemaData[k] {
walk(val)
}
}
case []any:
for _, e := range t {
walk(e)
}
}
}
for k, val := range def {
if k != "description" {
walk(val)
}
}
out, err := json.Marshal(def)
if err != nil {
return raw
}
return out
}