mesh/delivery-group group feat/registry-milestone checking: 7 of 7 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (4): unit: git.novox.be/novox/mesh-catalog/checks/gitignored.TestNoProgramABuildLeavesIsC…; and 3 more | 599046805156 on bab05c66b3cb
A group that changes mesh-vault's, node-launcher's or operator-channel's definition would otherwise meet the step state's credential guard again; the laid list goes only to the manifest validator and the composer, which read no verb's or message's schema. The test digests the definitions whole, as the reader holds them. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
344 lines
12 KiB
Go
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/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: the mesh's definitions are larger than a run input's
|
|
// bound (buildstep.RunInputBound).
|
|
|
|
// 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
|
|
}
|