diff --git a/checks/controllerdeps/controllerdeps.go b/checks/controllerdeps/controllerdeps.go index 0d784fb0..db0e4a40 100644 --- a/checks/controllerdeps/controllerdeps.go +++ b/checks/controllerdeps/controllerdeps.go @@ -10,6 +10,9 @@ // (`mesh-controller.`, `seat:mesh-controller.`, `mesh-controller_`), anywhere in the manifest; // - a manifest string that is the bare module name `mesh-controller` outside the module's own `config`, where a value // is the module's data (an issue owner naming the controller's repository is no dependency), not an address; +// - a go.mod of a module or of a check requiring the controller's repository (its module path, github.com/novox/mesh-controller, +// or the forge's git.novox.be/novox/mesh-controller): the repository is archived once its last module is delivered +// elsewhere (novox/hq ADR 0593 §11), so nothing here may be built from it; // - a line of any module's file naming one of the controller's buckets (`mesh-controller_`): its buckets are removed // after the controller's end (ADR 0590 §6), so a reader of one reads nothing. A test (`_test.go`, `.test.ts`) reads // no live bucket, its names are its own data, and a `testdata` directory is a copy of text from elsewhere (a design, @@ -40,6 +43,9 @@ const ( // address is a string naming the controller's seat, one of its states, verbs or events, or one of its buckets. var address = regexp.MustCompile(`(^|[^A-Za-z0-9_-])(seat:)?mesh-controller([._][A-Za-z0-9])`) +// repository is a go.mod line naming the controller's repository by either of its module paths. +var repository = regexp.MustCompile(`(github\.com|git\.novox\.be)/novox/mesh-controller(/|\s|$)`) + // Refusals is each reason the check gives over the tree at root, sorted; none when it passes. func Refusals(root string) ([]string, error) { var out []string @@ -65,6 +71,26 @@ func Refusals(root string) ([]string, error) { "consumes, waits on or uses it; name the seat that holds the job now (novox/hq ADR 0590 §5)", module, s)) } } + mods, err := filepath.Glob(filepath.Join(root, "*", "*", "go.mod")) + if err != nil { + return nil, err + } + sort.Strings(mods) + for _, f := range mods { + if rel, _ := filepath.Rel(root, f); !strings.HasPrefix(rel, "modules"+string(filepath.Separator)) && !strings.HasPrefix(rel, "checks"+string(filepath.Separator)) { + continue + } + raw, err := os.ReadFile(f) + if err != nil { + return nil, err + } + for n, l := range strings.Split(string(raw), "\n") { + if repository.MatchString(l) { + rel, _ := filepath.Rel(root, f) + out = append(out, fmt.Sprintf("%s:%d: requires the controller's repository: it is archived once its last module is built elsewhere, so nothing here is built from it (novox/hq ADR 0593 §11)", filepath.ToSlash(rel), n+1)) + } + } + } buckets, err := bucketLines(root) if err != nil { return nil, err diff --git a/checks/controllerdeps/controllerdeps_test.go b/checks/controllerdeps/controllerdeps_test.go index 9a2e221a..0a05e0ae 100644 --- a/checks/controllerdeps/controllerdeps_test.go +++ b/checks/controllerdeps/controllerdeps_test.go @@ -33,6 +33,9 @@ func TestRefusalsJudgeATree(t *testing.T) { {"a requirement", map[string]string{"modules/d/module.json": `{"module":"d","requires":[{"name":"x","from":"mesh-controller"}]}`}, `d: names "mesh-controller"`}, {"the controller's own module", map[string]string{"modules/mesh-controller/module.json": `{"module":"mesh-controller"}`}, "mesh-controller: the controller is no module of the catalogue"}, {"a bucket in code", map[string]string{"modules/e/cmd/e/main.go": "package main\nconst b = \"mesh-controller_assignments\"\n"}, "modules/e/cmd/e/main.go:2: names the controller's bucket"}, + {"a requirement of the controller's repository", map[string]string{"modules/h/go.mod": "module h\n\nrequire github.com/novox/mesh-controller v0.0.0\n"}, "modules/h/go.mod:3: requires the controller's repository"}, + {"its forge path in a check", map[string]string{"checks/i/go.mod": "module i\n\nrequire (\n\tgit.novox.be/novox/mesh-controller/modules/route-proxy v0.1.0\n)\n"}, "checks/i/go.mod:4"}, + {"another repository of the forge passes", map[string]string{"modules/j/go.mod": "module j\n\nrequire git.novox.be/novox/mesh-sdk/go v0.1.0\n"}, ""}, {"a bucket in a test is its data", map[string]string{"modules/e/cmd/e/main_test.go": "package main\n// mesh-controller_lease\n"}, ""}, {"a fixture copied from elsewhere", map[string]string{"modules/e/cmd/e/testdata/x.md": "mesh-controller_rollouts\n"}, ""}, {"an owner and prose pass", map[string]string{"modules/f/module.json": `{"module":"f","config":{"owners":{"default":["mesh-controller","mesh-controller modules/route-proxy"]}},"why":"the mesh-controller module is gone"}`}, ""}, diff --git a/checks/deps/main.go b/checks/deps/main.go index dae84e1e..702f35df 100644 --- a/checks/deps/main.go +++ b/checks/deps/main.go @@ -111,13 +111,24 @@ func forgeRequirements(dir string) ([]requirement, error) { if err != nil { return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out)) } - var mod struct{ Require []requirement } + var mod struct { + Require []requirement + Replace []struct{ Old, New requirement } + } if err := json.Unmarshal(out, &mod); err != nil { return nil, fmt.Errorf("its go.mod cannot be read: %w", err) } + // A requirement the go.mod replaces to a directory is a package of the module's own repository (novox/hq ADR 0593 §1): it + // is in the tree under check, so it is fetched from nowhere. + local := map[string]bool{} + for _, r := range mod.Replace { + if r.New.Version == "" { + local[r.Old.Path] = true + } + } var on []requirement for _, r := range mod.Require { - if strings.HasPrefix(r.Path, forge) { + if strings.HasPrefix(r.Path, forge) && !local[r.Path] { on = append(on, r) } } diff --git a/checks/deps/main_test.go b/checks/deps/main_test.go index 3328fbc4..047b2035 100644 --- a/checks/deps/main_test.go +++ b/checks/deps/main_test.go @@ -175,3 +175,28 @@ func TestTheRefOfAVersion(t *testing.T) { } } } + +// ADR 0593 §1: a module requires a sibling package of its own repository and replaces it to the directory beside it; that +// package is no fetch from the forge (it is in the tree under check), so it is never looked for in a clone beside the check, +// and the module builds with the SDK taken from there. +func TestASiblingPackageReplacedToItsDirectoryIsNotFetched(t *testing.T) { + offline(t) + beside, commit := sdkBeside(t) + version := "v0.1.14-0.20261012082113-" + commit[:12] + root := t.TempDir() + sibling := "git.novox.be/novox/mesh-catalog/modules/builder/runner" + write(t, filepath.Join(root, "builder", "runner", "go.mod"), "module "+sibling+"\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go "+version+"\n") + write(t, filepath.Join(root, "builder", "runner", "run.go"), "package runner\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\nfunc Run() string { return sdk.Hello() }\n") + dir := filepath.Join(root, "user") + write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/user\n\ngo 1.22\n\nrequire (\n\t"+sibling+" v0.0.0\n"+ + "\tgit.novox.be/novox/mesh-sdk/go "+version+"\n)\n\nreplace "+sibling+" => ../builder/runner\n") + write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \""+sibling+"\"\n\nfunc main() { println(runner.Run()) }\n") + modfile, err := resolve(dir, beside, t.TempDir()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if modfile == "" { + t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch the SDK from") + } + builds(t, dir, modfile) +} diff --git a/checks/removedpackages/go.mod b/checks/removedpackages/go.mod new file mode 100644 index 00000000..be738a7f --- /dev/null +++ b/checks/removedpackages/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/removedpackages + +go 1.22 diff --git a/checks/removedpackages/main.go b/checks/removedpackages/main.go new file mode 100644 index 00000000..d77f0d08 --- /dev/null +++ b/checks/removedpackages/main.go @@ -0,0 +1,166 @@ +// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that +// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left +// mesh-sdk's buildstep for mesh-build-go's package. +// +// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the +// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin. +// This check refuses it at once, from the source alone: +// +// - an import of a package in Removed (or one under it); +// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in +// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is +// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep). +// +// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out). +// +// go run . +// +// Exits 1 on a finding. The lists are the release's: a package that goes later is added here with the change. +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const sdk = "git.novox.be/novox/mesh-sdk/go" + +// Removed are the packages of mesh-sdk that left it, each with where its logic is now. +var Removed = map[string]string{ + "buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu", + "buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance", + "delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules", + "compose": "the node-engine, which alone composes (ADR 0593 §6)", + "compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)", + "compose/fromruntime": "the node-engine, which alone composes (ADR 0593 §6)", + "compose/judge": "the assigner's own package", + "takein": "the node-engine, which alone composes (ADR 0593 §6)", + "validate": "the node-engine, which alone composes (ADR 0593 §6)", + "facts": "nothing: the controller they served is ended (ADR 0590)", + "example": "nothing: no module imports it (ADR 0593 §12)", + "packageguard": "nothing: a test helper of mesh-sdk's own", +} + +// BuildstepContract are the names mesh-sdk's buildstep keeps: the failure contract, the fetch request, the stale-step sweep, +// the step id and the module tree shape the delivery parts read. +var BuildstepContract = map[string]bool{ + "AskWhat": true, "Boot": true, "By": true, "CapacitySetting": true, "Clip": true, "ConsoleFailure": true, "CutLine": true, + "EndOne": true, "EndStale": true, "EndStaleAtStart": true, "EndStaleRound": true, "Failure": true, "FetchRequest": true, + "FetchRequestProperties": true, "Hidden": true, "Launchers": true, "LauncherSuffix": true, "LineBytes": true, + "ModuleTree": true, "NewID": true, "OutputBytes": true, "Owner": true, "PackagesArg": true, "ParseFetchRequest": true, + "PassingLine": true, "RestartReason": true, "Scan": true, "ScanOf": true, "StaleAttempts": true, "StaleRetry": true, + "StaleServed": true, +} + +// Findings is every refusal in the Go sources under root, sorted. +func Findings(root string) ([]string, error) { + var out []string + for _, top := range []string{"modules", "checks"} { + err := filepath.WalkDir(filepath.Join(root, top), func(path string, d fs.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if d.IsDir() { + switch d.Name() { + case "testdata", "vendor", "node_modules", ".mesh-build": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + rel, _ := filepath.Rel(root, path) + found, err := fileFindings(path, filepath.ToSlash(rel)) + if err != nil { + return err + } + out = append(out, found...) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Strings(out) + return out, nil +} + +func fileFindings(path, rel string) ([]string, error) { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("%s: %w", rel, err) + } + var out []string + buildstep := "" + for _, imp := range file.Imports { + p, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + sub, ok := strings.CutPrefix(p, sdk+"/") + if !ok { + continue + } + for pkg, to := range Removed { + if sub == pkg || strings.HasPrefix(sub, pkg+"/") { + out = append(out, fmt.Sprintf("%s: imports %s, which left mesh-sdk; it is %s (novox/hq ADR 0593 §1)", rel, p, to)) + break + } + } + if sub == "buildstep" { + buildstep = "buildstep" + if imp.Name != nil { + buildstep = imp.Name.Name + } + } + } + if buildstep == "" || buildstep == "_" || buildstep == "." { + return out, nil + } + seen := map[string]bool{} + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + id, ok := sel.X.(*ast.Ident) + if !ok || id.Name != buildstep || !sel.Sel.IsExported() || BuildstepContract[sel.Sel.Name] || seen[sel.Sel.Name] { + return true + } + seen[sel.Sel.Name] = true + out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name)) + return true + }) + return out, nil +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: removedpackages ") + os.Exit(2) + } + found, err := Findings(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + for _, f := range found { + fmt.Println(f) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("no source imports a package mesh-sdk gave up") +} diff --git a/checks/removedpackages/main_test.go b/checks/removedpackages/main_test.go new file mode 100644 index 00000000..c5c403c4 --- /dev/null +++ b/checks/removedpackages/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ADR 0593 §1: a source that imports a package mesh-sdk gave up, or names the build runner through mesh-sdk's buildstep, is +// refused naming where it went; the contract that stays, the runner's own package and a testdata copy are not. +func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) { + const head = "package x\n\nimport (\n" + for _, row := range []struct { + name, file, src string + want string // empty: clean + }{ + {"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""}, + {"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"}, + {"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"}, + {"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"}, + {"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"}, + {"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"}, + {"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""}, + {"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""}, + {"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""}, + } { + t.Run(row.name, func(t *testing.T) { + root := t.TempDir() + p := filepath.Join(root, row.file) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(row.src), 0o644); err != nil { + t.Fatal(err) + } + got, err := Findings(root) + if err != nil { + t.Fatal(err) + } + if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) { + t.Fatalf("findings = %q, want one containing %q", all, row.want) + } + }) + } +} + +// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2). +func TestTheCatalogueImportsNothingMeshSDKGaveUp(t *testing.T) { + got, err := Findings(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + for _, f := range got { + t.Error(f) + } +} diff --git a/checks/removedpackages/reads-outside b/checks/removedpackages/reads-outside new file mode 100644 index 00000000..55f733da --- /dev/null +++ b/checks/removedpackages/reads-outside @@ -0,0 +1,6 @@ +# 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. +# every Go source of the modules and of the checks, whose imports are read. +modules +checks