Builds: design 47 G §712, §354, A-237 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
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/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
|
|
}
|