diff --git a/checks/sdkjudge/go.mod b/checks/sdkjudge/go.mod new file mode 100644 index 00000000..056ade86 --- /dev/null +++ b/checks/sdkjudge/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/sdkjudge + +go 1.22 diff --git a/checks/sdkjudge/main.go b/checks/sdkjudge/main.go new file mode 100644 index 00000000..ededc9eb --- /dev/null +++ b/checks/sdkjudge/main.go @@ -0,0 +1,180 @@ +// Package sdkjudge refuses mesh-sdk a manifest judge (novox/hq ADR 0578 §4): a package every module compiles in carries +// the contract only, so it holds no function that gathers a manifest's problems and no table of what a manifest may +// declare. The judge is a package of mesh-delivery that only the indexer and the checker use, and a reader decodes a +// manifest and takes the indexer's verdict (ADR 0578 §3). +// +// It reads mesh-sdk's source as the checker places it beside this check (MESH_CHECK_BESIDE, the repository mesh-sdk: at its +// trunk, or at the head of the group's pull request when the group holds one), so a change adding either to mesh-sdk in a +// delivery group with a catalogue change is refused here. A change to mesh-sdk alone is checked by mesh-sdk's own check, +// which does not run this package: the catalogue's check sees the SDK only beside a catalogue change. +// +// What it refuses, in the non-test Go files of mesh-sdk's go/ tree: +// - a function or method named for problems (a name ending in Problems or Problem) that takes a Manifest or a Shelf, and +// a function named ParseManifest, ParseManifestWith, ParseManifestForInputs, ValidateManifest... or ValidateShelf...: the +// judge's entry points; +// - in the package catalogue, a package-level variable named for what is allowed, permitted, removable, reserved or +// forbidden whose value is a map or a list: an allow-list table. +// +// The composer's refusals of composition itself (what a machine can be composed with) are not a judgement of a manifest +// (ADR 0578 §4, clause 5; issue 1585 tracks moving them): they are named in composition, each with why, and the list only +// shrinks, since a name that no longer exists is refused too. +package sdkjudge + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no +// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5). +var composition = map[string]string{} + +// Problems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, and the +// entries of composition it no longer holds. +func Problems(root string) ([]string, error) { + var out []string + seen := map[string]bool{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if n := d.Name(); n == "testdata" || n == "vendor" || strings.HasPrefix(n, ".") { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, _ := filepath.Rel(root, path) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return fmt.Errorf("%s: %w", rel, err) + } + inCatalogue := filepath.Base(filepath.Dir(path)) == "catalogue" + for _, d := range file.Decls { + switch d := d.(type) { + case *ast.FuncDecl: + if why := judgeFunc(d); why != "" { + out = append(out, note(composition, seen, rel, d.Name.Name, why)) + } + case *ast.GenDecl: + if !inCatalogue || d.Tok != token.VAR { + continue + } + for _, spec := range d.Specs { + v, _ := spec.(*ast.ValueSpec) + if v == nil { + continue + } + for i, name := range v.Names { + if i < len(v.Values) && allowName.MatchString(name.Name) && isTable(v.Values[i]) { + out = append(out, note(composition, seen, rel, name.Name, + "a table of what a manifest may declare (an allow-list)")) + } + } + } + } + } + return nil + }) + if err != nil { + return nil, err + } + var kept []string + for _, line := range out { + if line != "" { + kept = append(kept, line) + } + } + for key, why := range composition { + if !seen[key] { + kept = append(kept, fmt.Sprintf("composition lists %s (%s), which mesh-sdk no longer holds: take it off the list", key, why)) + } + } + sort.Strings(kept) + return kept, nil +} + +// note is the line refusing name in rel for why, or "" when composition lets it stay. +func note(composition map[string]string, seen map[string]bool, rel, name, why string) string { + key := rel + ":" + name + if _, ok := composition[key]; ok { + seen[key] = true + return "" + } + return fmt.Sprintf("%s: %s is %s; mesh-sdk carries the contract only, the judge is the indexer's and the checker's (ADR 0578 §4)", rel, name, why) +} + +// allowName is what names a table of what is allowed. +var allowName = regexp.MustCompile(`(?i)allow|permit|removable|reserved|forbid|denied`) + +// isTable says whether an expression is a map or a list literal of more than one entry. +func isTable(e ast.Expr) bool { + c, ok := e.(*ast.CompositeLit) + if !ok || len(c.Elts) < 2 { + return false + } + switch c.Type.(type) { + case *ast.MapType, *ast.ArrayType: + return true + } + return false +} + +// judgeFunc says why a function is a manifest judge, or "". +func judgeFunc(f *ast.FuncDecl) string { + name := f.Name.Name + if judgeEntry.MatchString(name) { + return "a function that judges a manifest" + } + if !strings.HasSuffix(name, "Problems") && !strings.HasSuffix(name, "Problem") { + return "" + } + if takes(f.Recv) || takes(f.Type.Params) { + return "a function that gathers a manifest's problems" + } + return "" +} + +// judgeEntry is the names of the judge's entry points: the strict parsers and the validators of a manifest or a shelf. +var judgeEntry = regexp.MustCompile(`^(ParseManifest(With|ForInputs)?|Validate(Manifest|Shelf)\w*)$`) + +// takes says whether a list of parameters names a Manifest or a Shelf anywhere in a type. +func takes(fields *ast.FieldList) bool { + if fields == nil { + return false + } + found := false + for _, f := range fields.List { + ast.Inspect(f.Type, func(n ast.Node) bool { + if id, ok := n.(*ast.Ident); ok && (id.Name == "Manifest" || id.Name == "Shelf") { + found = true + } + return !found + }) + } + return found +} + +// BesideDir is where the checker places mesh-sdk's clone, as a directory under MESH_CHECK_BESIDE; its go/ tree is judged. +func BesideDir() (string, error) { + beside := os.Getenv("MESH_CHECK_BESIDE") + if beside == "" { + return "", fmt.Errorf("NOT CHECKED: MESH_CHECK_BESIDE is not set, so mesh-sdk's source is not beside this check; the checker places the SDK there (besideRules)") + } + dir := filepath.Join(beside, "mesh-sdk", "go") + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("NOT CHECKED: mesh-sdk is not beside this check in MESH_CHECK_BESIDE=%s: %w", beside, err) + } + return dir, nil +} diff --git a/checks/sdkjudge/main_test.go b/checks/sdkjudge/main_test.go new file mode 100644 index 00000000..9eb598b4 --- /dev/null +++ b/checks/sdkjudge/main_test.go @@ -0,0 +1,66 @@ +package sdkjudge + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// mesh-sdk carries the contract only (ADR 0578 §4). A row is a source tree of its own: a function gathering a manifest's +// problems, a manifest parser that judges and a table of what a manifest may declare are refused, named; a Problems +// function over something that is no manifest, a test file, a table outside the catalogue package and a function that +// merely decodes are not. Then the SDK beside this check is judged. +func TestMeshSDKCarriesNoManifestJudge(t *testing.T) { + for _, row := range []struct { + name, dir, file, src, refused string + }{ + {name: "a function gathering a manifest's problems", dir: "catalogue", refused: "StreamProblems is a function that gathers a manifest's problems", + src: "package catalogue\ntype Manifest struct{}\nfunc StreamProblems(m Manifest) []string { return nil }\n"}, + {name: "a method over a manifest", dir: "catalogue", refused: "healthProblems is a function that gathers", + src: "package catalogue\ntype Manifest struct{}\nfunc (m Manifest) healthProblems() []string { return nil }\n"}, + {name: "a function over the shelf", dir: "catalogue", refused: "CatalogueProblems is a function that gathers", + src: "package catalogue\ntype Shelf map[string]int\nfunc CatalogueProblems(s Shelf) []string { return nil }\n"}, + {name: "a parser that judges", dir: "catalogue", refused: "ParseManifestWith is a function that judges a manifest", + src: "package catalogue\nfunc ParseManifestWith(raw []byte) error { return nil }\n"}, + {name: "a table of what may be removed", dir: "catalogue", refused: "removable is a table of what a manifest may declare", + src: "package catalogue\nvar removable = map[string]bool{\"A\": true, \"B\": true}\n"}, + {name: "a key parser", dir: "delivery", src: "package delivery\nfunc ParseManifestsKey(k string) error { return nil }\n"}, + {name: "a decoder", dir: "catalogue", src: "package catalogue\ntype Manifest struct{}\nfunc Decode(raw []byte) (Manifest, error) { return Manifest{}, nil }\n"}, + {name: "problems of something else", dir: "asks", src: "package asks\ntype Condition struct{}\nfunc DecodeProblems(c Condition) []string { return nil }\n"}, + {name: "a table beside the catalogue", dir: "asks", src: "package asks\nvar allowed = []string{\"a\", \"b\"}\n"}, + {name: "a test file", dir: "catalogue", file: "x_test.go", + src: "package catalogue\ntype Manifest struct{}\nfunc StreamProblems(m Manifest) []string { return nil }\n"}, + } { + root := t.TempDir() + dir := filepath.Join(root, row.dir) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if row.file == "" { + row.file = "x.go" + } + if err := os.WriteFile(filepath.Join(dir, row.file), []byte(row.src), 0o644); err != nil { + t.Fatal(err) + } + got, err := Problems(root) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(got, "\n") + if (row.refused == "") != (len(got) == 0) || !strings.Contains(joined, row.refused) { + t.Errorf("%s: refused %q, want %q", row.name, joined, row.refused) + } + } + dir, err := BesideDir() + if err != nil { + t.Fatal(err) + } + got, err := Problems(dir) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("mesh-sdk holds a manifest judge:\n%s", strings.Join(got, "\n")) + } +} diff --git a/checks/sdkjudge/reads-outside b/checks/sdkjudge/reads-outside new file mode 100644 index 00000000..7bdf4382 --- /dev/null +++ b/checks/sdkjudge/reads-outside @@ -0,0 +1,4 @@ +# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole; +# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept +# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing. +# None: the tests read mesh-sdk's source, the clone beside this repository (MESH_CHECK_BESIDE), which is no path of it.