BLD1: the Go build seat serves the verb steps, found by Go's convention
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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, <module>:<artifact>"},
|
||||
"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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
{
|
||||
"name": "node-build-go",
|
||||
"scope": "node",
|
||||
"serves": ["current", "drop", "pause", "resume"]
|
||||
"serves": ["current", "drop", "pause", "resume", "steps"]
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
|
||||
Reference in New Issue
Block a user