diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go index e18efc5c..21279f08 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main.go @@ -28,6 +28,7 @@ func run(args []string, getenv func(string) string, stdout, stderr io.Writer) in fs := flag.NewFlagSet("mesh-checking-program", flag.ContinueOnError) fs.SetOutput(stderr) verdict := fs.Bool("verdict", false, "answer the verdict of the facts in the input") + seats := fs.Bool("seats", false, "answer the seat list of the heads laid for it") if err := fs.Parse(args); err != nil { return 2 } @@ -35,8 +36,24 @@ func run(args []string, getenv func(string) string, stdout, stderr io.Writer) in fmt.Fprintf(stderr, "mesh-checking-program: "+format+"\n", a...) return 2 } + if *seats { + dir := getenv(delivery.RunHeadsEnv) + if dir == "" { + return refuse("no heads: %s names no directory", delivery.RunHeadsEnv) + } + answer, err := rules.SeatsOf(os.DirFS(dir)) + if err != nil { + return refuse("%v", err) + } + line, err := json.Marshal(answer) + if err != nil { + return refuse("the seat list cannot be written: %v", err) + } + fmt.Fprintf(stdout, "%s\n", line) + return 0 + } if !*verdict { - return refuse("nothing asked: give -verdict") + return refuse("nothing asked: give -verdict or -seats") } file := getenv(delivery.RunInputEnv) if file == "" { diff --git a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go index b45af7c6..352c822a 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-checking-program/main_test.go @@ -15,6 +15,8 @@ import ( // 1921 (B0, D2): the checking program answers, on its last line, the verdict the rules give the facts in its input, and // refuses facts of a version it does not read, an input it is not given, and a question it is not asked, naming each. +// 1921 (B0, D3): asked -seats, it answers the seat list of the heads laid for it: a definition a head adds, resolved with +// the parts it includes; what it left out, and why; and an empty list for heads with no definition. func TestTheCheckingProgramAnswersTheVerdictOfItsFacts(t *testing.T) { failing, _ := json.Marshal(rules.Tested{Level: delivery.LevelUnit, Tests: 3, Failed: []rules.Failure{{Package: "x/a", Test: "TestA", Cause: "a_test.go:9: boom"}}}) facts := rules.Facts{V: rules.FactsVersion, Group: "feat/x", Heads: "0123456789ab", Members: []rules.Member{{Repository: "novox/x", Number: 3, Head: "h"}}, @@ -35,22 +37,45 @@ func TestTheCheckingProgramAnswersTheVerdictOfItsFacts(t *testing.T) { later := facts later.V = rules.FactsVersion + 1 v2 := write("v2.json", later) + heads := filepath.Join(dir, "heads") + for p, body := range map[string]string{ + "novox/mesh-contracts/contracts/seats/node-build-x/seat.json": `{"format":1,"seat":"node-build-x","scope":"node","description":"kept","includes":["build"],"verbs":[{"name":"own"}]}`, + "novox/mesh-contracts/contracts/parts/build.json": `{"part":"build","verbs":[{"name":"current"}],"events":[{"name":"ended"}]}`, + "novox/mesh-contracts/contracts/seats/broken/seat.json": `{"format":1,"seat":"broken","scope":"nowhere"}`, + } { + if err := os.MkdirAll(filepath.Dir(filepath.Join(heads, p)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(heads, p), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + none := filepath.Join(dir, "no-heads") + if err := os.MkdirAll(filepath.Join(none, "novox/x"), 0o755); err != nil { + t.Fatal(err) + } for name, c := range map[string]struct { - args []string - input string - exit int - said string + args []string + input, heads string + exit int + said string }{ - "the facts": {[]string{"-verdict"}, v1, 0, ""}, - "a later version": {[]string{"-verdict"}, v2, 2, "facts of version 2, this checking program reads version 1"}, - "no input": {[]string{"-verdict"}, "", 2, delivery.RunInputEnv + " names no file"}, - "nothing asked": {nil, v1, 2, "nothing asked"}, - "an unknown asking": {[]string{"-judge"}, v1, 2, "-judge"}, + "the seats": {args: []string{"-seats"}, heads: heads}, + "no seats": {args: []string{"-seats"}, heads: none}, + "no heads laid": {args: []string{"-seats"}, exit: 2, said: delivery.RunHeadsEnv + " names no directory"}, + "the facts": {args: []string{"-verdict"}, input: v1}, + "a later version": {args: []string{"-verdict"}, input: v2, exit: 2, said: "facts of version 2, this checking program reads version 1"}, + "no input": {args: []string{"-verdict"}, exit: 2, said: delivery.RunInputEnv + " names no file"}, + "nothing asked": {input: v1, exit: 2, said: "nothing asked"}, + "an unknown asking": {args: []string{"-judge"}, input: v1, exit: 2, said: "-judge"}, } { var out, errs bytes.Buffer exit := run(c.args, func(k string) string { - if k == delivery.RunInputEnv { + switch k { + case delivery.RunInputEnv: return c.input + case delivery.RunHeadsEnv: + return c.heads } return "" }, &out, &errs) @@ -62,6 +87,23 @@ func TestTheCheckingProgramAnswersTheVerdictOfItsFacts(t *testing.T) { continue } lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if c.heads != "" { + var sa rules.SeatsAnswer + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &sa); err != nil || sa.V != rules.SeatsVersion || sa.Seats == nil { + t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err) + } + if c.heads == none { + if len(sa.Seats) != 0 || len(sa.Left) != 0 { + t.Errorf("%s: %+v, want an empty list", name, sa) + } + continue + } + want := `{"description":"kept","events":[{"name":"ended"}],"format":1,"scope":"node","seat":"node-build-x","verbs":[{"name":"current"},{"name":"own"}]}` + if len(sa.Seats) != 1 || string(sa.Seats[0]) != want || len(sa.Left) != 1 || !strings.Contains(sa.Left[0].Why, `not "broken" and "nowhere"`) { + t.Errorf("%s: seats %s, left %+v; want %s and broken left out", name, sa.Seats, sa.Left, want) + } + continue + } var a rules.Answer if err := json.Unmarshal([]byte(lines[len(lines)-1]), &a); err != nil || a.V != rules.AnswerVersion { t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err) diff --git a/modules/mesh-delivery-checker/rules/seats.go b/modules/mesh-delivery-checker/rules/seats.go new file mode 100644 index 00000000..1ef80fcd --- /dev/null +++ b/modules/mesh-delivery-checker/rules/seats.go @@ -0,0 +1,142 @@ +package rules + +import ( + "encoding/json" + "fmt" + "io/fs" + "maps" + "path" + "slices" + "strings" +) + +// The seat list a check makes from its heads (novox/hq ADR 0636 §4, ADR 0644 §1; the lead's ruling Q2 on milestone +// 47/B0): the checking program built from the heads reads the seat definitions they carry and answers them resolved, so +// the running checker hands the manifest validator and the composer bytes it does not read itself. + +// 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 (the verbs and +// events of the parts it includes put first, as the indexer keeps it), as the requests' field `seats` carries them; and +// each definition left out, with why. +type SeatsAnswer struct { + V int `json:"v"` + Seats []json.RawMessage `json:"seats"` + Left []LeftOut `json:"left,omitempty"` +} + +// LeftOut is a seat definition the list does not hold: its file and why. +type LeftOut struct { + Path string `json:"path"` + Why string `json:"why"` +} + +// SeatsOf reads the seat definitions in heads, a directory holding each head at its repository's path: a definition is a +// contracts module's `seats//seat.json`, with the parts it includes from the `parts/.json` beside that +// `seats` directory (design 26, *A seat's definition*). A file that is not a definition, a part it names that is not +// there, and a seat defined in more than one place are left out, said; heads with no definition give an empty list. +func SeatsOf(heads fs.FS) (SeatsAnswer, error) { + out := SeatsAnswer{V: SeatsVersion, Seats: []json.RawMessage{}} + var files []string + err := fs.WalkDir(heads, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && (d.Name() == ".git" || d.Name() == "testdata") { + return fs.SkipDir + } + if !d.IsDir() && d.Name() == "seat.json" && path.Base(path.Dir(path.Dir(p))) == "seats" { + files = append(files, p) + } + return nil + }) + if err != nil { + return SeatsAnswer{}, fmt.Errorf("rules: the heads cannot be read: %w", err) + } + type found struct { + path string + raw json.RawMessage + } + bySeat := map[string][]found{} + for _, p := range files { + raw, err := resolvedDefinition(heads, p) + if err != nil { + out.Left = append(out.Left, LeftOut{Path: p, Why: err.Error()}) + continue + } + var d definitionHead + _ = json.Unmarshal(raw, &d) + bySeat[d.Seat] = append(bySeat[d.Seat], found{p, raw}) + } + for _, seat := range slices.Sorted(maps.Keys(bySeat)) { + fs := bySeat[seat] + if len(fs) > 1 { + var paths []string + for _, f := range fs { + paths = append(paths, f.path) + } + for _, f := range fs { + out.Left = append(out.Left, LeftOut{Path: f.path, Why: "the seat " + seat + " is defined in more than one place: " + strings.Join(paths, ", ")}) + } + continue + } + out.Seats = append(out.Seats, fs[0].raw) + } + return out, nil +} + +// definitionHead is what of a definition the list is made by: its seat, its scope and the parts it includes. The rest is +// carried as written, for the manifest validator and the composer to read (milestone G gives this the domain SDK's one +// reading). +type definitionHead struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Includes []string `json:"includes"` +} + +// resolvedDefinition is the definition at p with its parts' verbs and events put first and no includes, every field it +// carries kept as written. +func resolvedDefinition(heads fs.FS, p string) (json.RawMessage, error) { + raw, err := fs.ReadFile(heads, p) + if err != nil { + return nil, err + } + var d definitionHead + if err := json.Unmarshal(raw, &d); err != nil { + return nil, fmt.Errorf("a seat definition is not JSON: %w", err) + } + if d.Seat == "" || (d.Scope != "mesh" && d.Scope != "node") { + return nil, fmt.Errorf("a seat definition names its seat and its scope (mesh or node), not %q and %q", d.Seat, d.Scope) + } + var whole map[string]json.RawMessage + if err := json.Unmarshal(raw, &whole); err != nil { + return nil, err + } + var verbs, events []json.RawMessage + partsDir := path.Join(path.Dir(path.Dir(path.Dir(p))), "parts") + for _, name := range d.Includes { + praw, err := fs.ReadFile(heads, path.Join(partsDir, name+".json")) + if err != nil { + return nil, fmt.Errorf("the seat %s includes the part %q, which is not at %s", d.Seat, name, partsDir) + } + var part struct{ Verbs, Events []json.RawMessage } + if err := json.Unmarshal(praw, &part); err != nil { + return nil, fmt.Errorf("the part %q of the seat %s is not JSON: %w", name, d.Seat, err) + } + verbs, events = append(verbs, part.Verbs...), append(events, part.Events...) + } + var own struct{ Verbs, Events []json.RawMessage } + _ = json.Unmarshal(raw, &own) + verbs, events = append(verbs, own.Verbs...), append(events, own.Events...) + delete(whole, "includes") + for k, list := range map[string][]json.RawMessage{"verbs": verbs, "events": events} { + if len(list) == 0 { + delete(whole, k) + continue + } + b, _ := json.Marshal(list) + whole[k] = b + } + return json.Marshal(whole) +}