From cfad86394f5483422e9ba74e0d6d7110368218bc Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 10:54:55 +0100 Subject: [PATCH] BLD1: the Go build seat serves the verb steps, found by Go's convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verb fetches the artifact's tree and answers lint always, unit and integration steps by the packages holding test files (integration by the tag), a level with no test left out; the manifest serves it. Builds: design 47 G §22, §307, §28 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-build-go/cmd/mesh-build-go/asks.go | 2 +- .../cmd/mesh-build-go/asks_test.go | 2 +- .../mesh-build-go/cmd/mesh-build-go/steps.go | 198 ++++++++++++++++++ .../cmd/mesh-build-go/steps_test.go | 50 +++++ modules/mesh-build-go/module.json | 2 +- 5 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/steps.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/steps_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go index 415f217bf..a1fcf324a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -134,7 +134,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep // SeatTools are the seat's verbs on this node (ADR 0525 §10): what it runs, and its pause and resume. func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool { - return buildstep.SeatTools(Seat, "Go", p) + return append(buildstep.SeatTools(Seat, "Go", p), m.stepsTool()) } // StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go index c866703c9..810569bb7 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks_test.go @@ -118,7 +118,7 @@ func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b } // the node's build capacity as a setting, with the builder's default; the seat's verbs are served as its tools. func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) { m := readManifest(t) - if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "drop", "pause", "resume"}) { + if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "drop", "pause", "resume", "steps"}) { t.Fatalf("claims = %+v", m.Claims) } if !slices.Contains(m.Reads, KeptState) { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/steps.go b/modules/mesh-build-go/cmd/mesh-build-go/steps.go new file mode 100644 index 000000000..2aa35e58d --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/steps.go @@ -0,0 +1,198 @@ +package main + +// **The verb `steps`** (design 47 G §22, §307; the contract's verb of the seat `node-build-go`): which pipeline steps an +// artifact can run, found by Go's convention. Lint is always one; unit tests are the packages that hold a test file, +// integration tests those that hold a test file built only with the tag `integration` (the tag the integration level +// runs with, levels.go). A level with no package is not answered: the pipeline has nothing to run there (§22, §328). + +import ( + "context" + "fmt" + "go/build/constraint" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo" + sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The pipeline steps `steps` answers (the contract's DetectedStep enum). +const ( + StepLint = "lint" + StepUnit = "unit" + StepIntegration = "integration" +) + +// DetectedStep is the contract's. +type DetectedStep = seat.DetectedStep + +// testLevel says, for a test file's header, which level it belongs to: unit when it is built without the tag +// integration, integration when it is built only with it, "" when neither (another platform's file). +func testLevel(header string) string { + base := func(tag string) bool { return tag == "linux" || tag == "amd64" || tag == "unix" || tag == "gc" } + for _, line := range strings.Split(header, "\n") { + line = strings.TrimSpace(line) + if !constraint.IsGoBuild(line) { + if strings.HasPrefix(line, "package ") { + break + } + continue + } + expr, err := constraint.Parse(line) + if err != nil { + return "" + } + switch { + case expr.Eval(base): + return StepUnit + case expr.Eval(func(tag string) bool { return tag == "integration" || base(tag) }): + return StepIntegration + } + return "" + } + return StepUnit +} + +// detectSteps finds the steps of the Go packages under root (the artifact's directory in the fetched tree): the import +// path of each package is its go.mod's module path and its directory below that go.mod. +func detectSteps(root string) ([]DetectedStep, error) { + found := map[string]map[string]bool{StepUnit: {}, StepIntegration: {}} + var walk func(dir, mod, modDir string) error + walk = func(dir, mod, modDir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + if raw, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { + mod, modDir = modulePath(string(raw)), dir + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + if name == "testdata" || name == "vendor" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + if err := walk(filepath.Join(dir, name), mod, modDir); err != nil { + return err + } + continue + } + if !strings.HasSuffix(name, "_test.go") || mod == "" { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return err + } + level := testLevel(string(raw[:min(len(raw), 4096)])) + if level == "" { + continue + } + rel, err := filepath.Rel(modDir, dir) + if err != nil { + return err + } + pkg := mod + if rel != "." { + pkg = path.Join(mod, filepath.ToSlash(rel)) + } + found[level][pkg] = true + } + return nil + } + if err := walk(root, "", root); err != nil { + return nil, err + } + steps := []DetectedStep{{Step: StepLint}} + for _, level := range []string{StepUnit, StepIntegration} { + if len(found[level]) == 0 { + continue + } + pkgs := make([]string, 0, len(found[level])) + for p := range found[level] { + pkgs = append(pkgs, p) + } + sort.Strings(pkgs) + steps = append(steps, DetectedStep{Step: level, Packages: pkgs}) + } + return steps, nil +} + +// StepsAnswer is what `steps` answers. +type StepsAnswer struct { + Artifact string `json:"artifact"` + Fingerprint string `json:"fingerprint,omitempty"` + Steps []DetectedStep `json:"steps"` + Kept bool `json:"kept,omitempty"` +} + +// stepsTool is the seat's verb `steps`: it fetches the artifact's commit into a workspace of its own, only the tree (no +// module download: the detection reads files), finds the steps and removes the workspace. +func (m *Module) stepsTool() stdio.Tool { + return stdio.Tool{Name: Seat + "." + seat.VerbSteps, + Description: "Which pipeline steps a Go artifact can run, found by Go's convention: lint always, unit tests where a package holds a test file, " + + "integration tests where one holds a test file built with the tag integration. A level with no test is not answered.", + Input: map[string]any{"type": "object", "required": []string{"module", "artifact", "fetch"}, "properties": map[string]any{ + "module": map[string]any{"type": "string"}, + "artifact": map[string]any{"type": "string", "description": "the artifact reference, :"}, + "fingerprint": map[string]any{"type": "string"}, + "fetch": map[string]any{"type": "object", "description": "what the fetch tool is asked: the repository, the full commit and the module's directory"}, + "again": map[string]any{"type": "boolean", "description": "find them anew, not from the kept answer"}, + }}, + Run: m.Short(m.steps)} +} + +func (m *Module) steps(ctx context.Context, args map[string]any) (any, error) { + str := func(k string) string { s, _ := args[k].(string); return s } + fetch, _ := args["fetch"].(map[string]any) + req, err := sdkstep.ParseFetchRequest(fetch) + if err != nil { + return nil, err + } + if str("artifact") == "" || str("module") == "" { + return nil, fmt.Errorf("steps: the module and the artifact are asked") + } + steps, err := m.stepsOfTree(ctx, req) + if err != nil { + return nil, err + } + return StepsAnswer{Artifact: str("artifact"), Fingerprint: str("fingerprint"), Steps: steps}, nil +} + +// stepsOfTree fetches the commit into a workspace removed at the end and detects the steps of the module's directory. +func (m *Module) stepsOfTree(ctx context.Context, req sdkstep.FetchRequest) ([]DetectedStep, error) { + if err := os.MkdirAll(m.Work, 0o700); err != nil { + return nil, err + } + root, err := os.MkdirTemp(m.Work, "steps-") + if err != nil { + return nil, err + } + defer os.RemoveAll(root) + tree := filepath.Join(root, "tree") + if err := os.MkdirAll(tree, 0o700); err != nil { + return nil, err + } + if err := buildstep.FetchCommit(ctx, m.Run, tree, m.remote(req.Repository), req.Commit, req.Ref, m.fetchOptions(ctx, req.Repository, req.Commit, req.Ref)...); err != nil { + return nil, err + } + if len(req.Paths) > 0 { + if err := buildstep.Narrow(tree, req); err != nil { + return nil, err + } + } + dir := tree + if req.Dir != "" && req.Dir != "." { + dir = filepath.Join(tree, filepath.FromSlash(req.Dir)) + } + if _, err := fs.Stat(os.DirFS(dir), "."); err != nil { + return nil, fmt.Errorf("steps: the module's directory %q is not in the commit: %w", req.Dir, err) + } + return detectSteps(dir) +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go b/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go new file mode 100644 index 000000000..c2a118fbf --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/steps_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" + "reflect" + "testing" +) + +// Design 47 G §22, §307 (BLD1): the verb steps answers an artifact's lint, unit and integration steps by Go's +// convention and leaves out a level with no test. +func TestTheStepsVerbAnswersTheStepsOfAFixtureArtifact(t *testing.T) { + f := &fakeStep{files: map[string]string{ + "go.mod": "module example.test/mod\n\ngo 1.26\n", + "a/a_test.go": "package a\n", + "a/b_integration_test.go": "//go:build integration\n\npackage a\n", + "b/b_test.go": "//go:build !windows\n\npackage b\n", + "c/c_test.go": "//go:build integration\n\npackage c\n", + "d/d_test.go": "//go:build windows\n\npackage d\n", + "testdata/x/x_test.go": "package x\n", + "e/e.go": "package e\n", + }} + m, _ := fetchModule(t, f) + var run func(map[string]any) (any, error) + for _, tool := range m.SeatTools(m.Puller(nil, buildstep.NewFakeAsks())) { + if tool.Name == Seat+".steps" { + run = tool.Run + } + } + if run == nil { + t.Fatal("the module serves no verb steps") + } + got, err := run(map[string]any{"module": "mod", "artifact": "mod:mod", "fingerprint": "fp1", + "fetch": map[string]any{"repository": "novox/mod", "commit": commitSHA}}) + if err != nil { + t.Fatal(err) + } + want := []DetectedStep{{Step: "lint"}, + {Step: "unit", Packages: []string{"example.test/mod/a", "example.test/mod/b"}}, + {Step: "integration", Packages: []string{"example.test/mod/a", "example.test/mod/c"}}} + if a := got.(StepsAnswer); !reflect.DeepEqual(a.Steps, want) || a.Artifact != "mod:mod" { + t.Fatalf("steps = %+v, want %+v", a, want) + } + // A module with no test answers lint alone: its unit and integration levels have nothing to run. + f.files = map[string]string{"go.mod": "module example.test/mod\n", "mod/e/e.go": "package e\n"} + got, err = run(map[string]any{"module": "mod", "artifact": "mod:mod", + "fetch": map[string]any{"repository": "novox/mod", "commit": commitSHA}}) + if err != nil || !reflect.DeepEqual(got.(StepsAnswer).Steps, []DetectedStep{{Step: "lint"}}) { + t.Fatalf("a module with no test: %+v, %v", got, err) + } +} diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index b337cd644..fb3655599 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -21,7 +21,7 @@ { "name": "node-build-go", "scope": "node", - "serves": ["current", "drop", "pause", "resume"] + "serves": ["current", "drop", "pause", "resume", "steps"] } ], "consumes": [