CAT-BG5: mesh-build-go regenerates a contracts module's Go files and names each out of step

The unit level fails on a file that differs, is missing or was not generated; a build
ask answers the same files in its out-of-step, a failed build's too, so a trunk build's
end raises generated-types-out-of-step (lead's ruling on A2k finding 1). The compare is
buildstep's, each build module its own root, with the seat generator as a requirement.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 15:06:54 +01:00
parent aaca1c6ca9
commit c5bec10bb5
10 changed files with 378 additions and 0 deletions
+6
View File
@@ -142,6 +142,12 @@ trunk build's, in chunks, only what the holder lacks; the holder asks the builde
seat's events. The step answers the seat's form `oci-artifact-registry/<module>/<artifact>@<digest>` (ADR 0605 §9); the
builder writes the older form beside it while a counted node-engine reads only that (ADR 0624); a publish the holder refuses, or a
seat nothing holds, fails the step `not stored: <artifact>: <the holder's words>`. The packed bytes never stay on the node.
A contracts module (a package artifact naming `generated-from`) has its generated Go files regenerated with the seat
generator this module is built with and compared (`buildstep/generated.go`): the unit level of `build_go_test` fails
naming each file that differs, is missing or was not written by the generator (its `out-of-step`), and a build ask
answers the same files in its answer's `out-of-step`, a failed build's too, so a trunk build's end can raise
`generated-types-out-of-step` where no test step runs. Its TypeScript files are mesh-build-node's to compare.
The module writes into no store itself and requires no `artifact-store`. A Go package (kind `package`, language `go`; `gopackage.go`) is
the directory its one path names packed as Go's module zip, every file under `<module path>@<version>/`, its digest the
zip's (no `h1:` sum is answered: nothing carries it on); a trunk build the builder gave a version publishes it through
+26
View File
@@ -29,6 +29,10 @@ type AskSteps struct {
// {"imports": [...]}, sorted, from the workspace the build ran in. A build that built an artifact asks it once, at
// its end; nil reports none.
Imports StepFunc
// Compare regenerates a contracts module's generated files (a package artifact naming generated-from) and answers
// {"out-of-step": [...]} (CompareStep): a build ask runs it once on the fetched workspace and answers the paths in
// its out-of-step, a failed build's too, so a trunk build's end names them where no test step runs. Nil compares none.
Compare StepFunc
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
// (an image or an archive names none).
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
@@ -156,6 +160,24 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
}
}
f = got
if s.Compare != nil {
Phase(ctx, "compare the generated files")
out, err := s.Compare(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path})
if err != nil {
return nil, fmt.Errorf("the generated files of %s: %w", job.Module, err)
}
r, err := raw(out)
if err != nil {
return nil, err
}
var read struct {
OutOfStep []string `json:"out-of-step"`
}
if err := json.Unmarshal(r, &read); err != nil {
return nil, fmt.Errorf("the compare of %s answered what is not a list: %s", job.Module, r)
}
answer.OutOfStep = read.OutOfStep
}
}
var (
out any
@@ -186,6 +208,10 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
out, err = s.Build(ctx, args)
}
if err != nil {
// The compare's out-of-step is answered with the failure, so the build's end still names it.
if len(answer.OutOfStep) > 0 {
return answer, fmt.Errorf("%s: %w", a.Artifact, err)
}
return nil, fmt.Errorf("%s: %w", a.Artifact, err)
}
r, err := raw(out)
@@ -0,0 +1,132 @@
package buildstep
// **Regenerate and compare** (CAT-BG5, CAT-BN2; novox/hq ADR 0608 §5, design 26 *Contracts modules*). A module whose
// package artifact names `generated-from` holds contracts only: every file under its generated roots is written by the
// seat generator (seatgen) from the definitions in that directory. A build module regenerates them with the generator
// it was built with (its version is this package's requirement, so a new generator is a new build module and a new
// fingerprint of what it builds) and names each file out of step: one that differs, one missing, one under a generated
// root the generator did not write. The test step fails on any; a trunk build's ask answers them in its out-of-step
// (delivery.BuildAnswer.OutOfStep), so the builder's build end raises generated-types-out-of-step even where no test
// step runs. Each build module compares its own roots: mesh-build-go `go`, mesh-build-node `ts`.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/seatgen/seatgen"
)
// GeneratedRootGo and GeneratedRootTS are the generated roots of a contracts module, one per build module.
const (
GeneratedRootGo = "go"
GeneratedRootTS = "ts"
)
var goModuleLine = regexp.MustCompile(`(?m)^module\s+(\S+)`)
// OutOfStep is every file under root, a path from the module's directory, that the generator writes otherwise than the
// module holds it, for each `generated-from` its package artifacts name, sorted and each once; none for a module naming
// none. The generated Go tree's module path is read from <from>/go/go.mod and the TypeScript package's name from
// <from>/ts/package.json, as the contracts module states them.
func OutOfStep(moduleDir, root string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil // no manifest here: nothing names generated-from
}
if err != nil {
return nil, fmt.Errorf("the module's manifest cannot be read for its generated files: %w", err)
}
var m catalogue.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("the module's manifest does not parse: %w", err)
}
froms := map[string]bool{}
if m.Build != nil {
for _, a := range m.Build.Artifacts {
if a.GeneratedFrom != "" {
froms[filepath.Clean(a.GeneratedFrom)] = true
}
}
}
seen := map[string]bool{}
for from := range froms {
if filepath.IsAbs(from) || from == ".." || strings.HasPrefix(from, "../") {
return nil, fmt.Errorf("generated-from %q is not inside the module", from)
}
dir := filepath.Join(moduleDir, from)
goModule, tsPackage, err := generatedNames(dir)
if err != nil {
return nil, err
}
g, err := seatgen.Load(dir, goModule, tsPackage)
if err != nil {
return nil, fmt.Errorf("the seat definitions in %s do not load: %w", from, err)
}
files, err := seatgen.Generate(g)
if err != nil {
return nil, fmt.Errorf("the seat definitions in %s do not generate: %w", from, err)
}
out, err := seatgen.Compare(dir, files)
if err != nil {
return nil, err
}
for _, line := range out {
p, _, _ := strings.Cut(line, ": ")
if p == root || strings.HasPrefix(p, root+"/") {
seen[filepath.ToSlash(filepath.Join(from, p))] = true
}
}
}
paths := make([]string, 0, len(seen))
for p := range seen {
paths = append(paths, p)
}
sort.Strings(paths)
return paths, nil
}
func generatedNames(dir string) (goModule, tsPackage string, err error) {
if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootGo, "go.mod")); err == nil {
if m := goModuleLine.FindSubmatch(raw); m != nil {
goModule = string(m[1])
}
}
if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootTS, "package.json")); err == nil {
var p struct {
Name string `json:"name"`
}
if json.Unmarshal(raw, &p) == nil {
tsPackage = p.Name
}
}
if goModule == "" || tsPackage == "" {
return "", "", errors.New("a contracts module names its generated Go module in go/go.mod and its TypeScript package in ts/package.json; " +
filepath.Base(dir) + " names " + map[bool]string{true: "not the TypeScript package", false: "not the Go module"}[goModule != ""])
}
return goModule, tsPackage, nil
}
// CompareStep is AskSteps.Compare for a build module comparing root: it reads {"workspace", "module"}, the workspace's
// tree where tree says it is (the build module's own layout), and answers {"out-of-step": [...]}.
func CompareStep(root string, tree func(workspace string) string) StepFunc {
return func(_ context.Context, args map[string]any) (any, error) {
ws, _ := args["workspace"].(string)
mod, _ := args["module"].(string)
if ws == "" {
return nil, Refuse("the compare names no workspace")
}
paths, err := OutOfStep(filepath.Join(tree(ws), filepath.FromSlash(mod)), root)
if err != nil {
return nil, err
}
return map[string]any{"out-of-step": paths}, nil
}
}
@@ -0,0 +1,134 @@
package buildstep
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/seatgen/seatgen"
)
// contracts writes a contracts module of one seat, its generated files as the generator writes them, and answers its
// directory and the generated paths.
func contracts(t *testing.T, manifest string) (string, []string) {
t.Helper()
dir := t.TempDir()
for p, text := range map[string]string{
"module.json": manifest,
"seats/index.json": `{"format":1,"seats":["one-store"]}`,
"seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`,
} {
writeContract(t, filepath.Join(dir, p), text)
}
m, err := seatgen.Load(dir, "example.org/c/go", "@example/c")
if err != nil {
t.Fatal(err)
}
files, err := seatgen.Generate(m)
if err != nil {
t.Fatal(err)
}
var paths []string
for p, b := range files {
writeContract(t, filepath.Join(dir, p), string(b))
paths = append(paths, p)
}
// The names the compare reads, where the contracts module states them; a generated go.mod or package.json is kept.
if _, ok := files["go/go.mod"]; !ok {
writeContract(t, filepath.Join(dir, "go/go.mod"), "module example.org/c/go\n")
}
if _, ok := files["ts/package.json"]; !ok {
writeContract(t, filepath.Join(dir, "ts/package.json"), `{"name":"@example/c"}`)
}
return dir, paths
}
func writeContract(t *testing.T, path, text string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
// **CAT-BG5, CAT-BN2**: a contracts module's generated files are regenerated and compared, each build module its own
// root; a file that differs, one missing and one the generator did not write are each named; a clean tree and a module
// naming no generated-from name none; a build ask answers them in its out-of-step.
func TestAContractsModulesGeneratedFilesAreComparedWithTheGenerator(t *testing.T) {
marked := `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}`
dir, paths := contracts(t, marked)
var goFile, tsFile string
for _, p := range paths {
if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, ".go") && goFile == "" {
goFile = p
}
if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, ".ts") && tsFile == "" {
tsFile = p
}
}
if goFile == "" || tsFile == "" {
t.Fatalf("the generator wrote no Go or no TypeScript file: %v", paths)
}
for _, root := range []string{GeneratedRootGo, GeneratedRootTS} {
if got, err := OutOfStep(dir, root); err != nil || len(got) != 0 {
t.Fatalf("a clean tree, root %s: %v %v", root, got, err)
}
}
writeContract(t, filepath.Join(dir, goFile), "package changed\n")
writeContract(t, filepath.Join(dir, "go/extra.go"), "package extra\n")
writeContract(t, filepath.Join(dir, tsFile), "// changed\n")
got, err := OutOfStep(dir, GeneratedRootGo)
if want := sorted(goFile, "go/extra.go"); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("Go root: %v %v, want %v", got, err, want)
}
if got, _ := OutOfStep(dir, GeneratedRootTS); !reflect.DeepEqual(got, []string{tsFile}) {
t.Errorf("TypeScript root: %v, want %s alone", got, tsFile)
}
_ = os.Remove(filepath.Join(dir, goFile))
if got, _ := OutOfStep(dir, GeneratedRootGo); !reflect.DeepEqual(got, sorted(goFile, "go/extra.go")) {
t.Errorf("a missing file is not named: %v", got)
}
plain, _ := contracts(t, `{"module":"p","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]}]}}`)
writeContract(t, filepath.Join(plain, "go/extra.go"), "package extra\n")
if got, err := OutOfStep(plain, GeneratedRootGo); err != nil || len(got) != 0 {
t.Errorf("a module naming no generated-from compared: %v %v", got, err)
}
// The build ask answers them beside what it built, and with a failed build too.
built := 0
steps := AskSteps{Compare: CompareStep(GeneratedRootGo, func(ws string) string { return ws }),
Fetch: func(context.Context, map[string]any) (any, error) {
return map[string]any{"workspace": filepath.Dir(dir)}, nil
},
Build: func(context.Context, map[string]any) (any, error) {
if built++; built > 1 {
return nil, errors.New("the second artifact does not build")
}
return map[string]any{"digest": "sha256:aa"}, nil
}}
job := delivery.BuildJob{Module: "c", Path: filepath.Base(dir), Fetch: map[string]any{"repository": "novox/c"},
Artifacts: []delivery.AskArtifact{{Artifact: "go"}}}
out, err := RunAsk(context.Background(), steps, ask(job))
if a, ok := out.(delivery.BuildAnswer); err != nil || !ok || !reflect.DeepEqual(a.OutOfStep, sorted(goFile, "go/extra.go")) || len(a.Artifacts) != 1 {
t.Errorf("the build ask's out-of-step: %+v %v", out, err)
}
job.Artifacts = append(job.Artifacts, delivery.AskArtifact{Artifact: "go-other"})
built = 0
out, err = RunAsk(context.Background(), steps, ask(job))
if a, ok := out.(delivery.BuildAnswer); err == nil || !ok || len(a.OutOfStep) != 2 {
t.Errorf("a failed build answered no out-of-step: %+v %v", out, err)
}
}
func sorted(paths ...string) []string {
sort.Strings(paths)
return paths
}
+1
View File
@@ -4,6 +4,7 @@ go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
git.novox.be/novox/seatgen v0.0.0
github.com/nats-io/nats.go v1.54.0
)
+6
View File
@@ -83,6 +83,12 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf
step.State, step.Reason = delivery.StepRefused, err.Error()
default:
step.State, step.Reason = delivery.StepFailed, err.Error()
// A failed step may answer what it found on the way (a build's out-of-step files): kept on its end.
if result != nil {
if raw, merr := json.Marshal(result); merr == nil {
step.Result = raw
}
}
}
werr := writeEnd(put, key, step)
if werr == nil {
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"time"
"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
@@ -38,6 +39,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
return buildstep.AskSteps{
Fetch: byName["build_go_fetch"], Build: byName["build_go_build"], Digest: byName["build_go_digest"], Test: byName["build_go_test"],
Imports: byName[ImportsTool],
Compare: buildstep.CompareStep(buildstep.GeneratedRootGo, func(ws string) string { return filepath.Join(m.Work, ws, "tree") }),
Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) {
installed, err := m.Probe(ctx)
if err != nil {
@@ -72,6 +72,9 @@ type TestResult struct {
// JudgedBy is the program that ran the level (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
// OutOfStep are the generated files of a contracts module in the workspace out of step with what the generator writes
// (CAT-BG5, buildstep.OutOfStep): the unit level fails on any, Why naming each. Absent when none is.
OutOfStep []string `json:"out-of-step,omitempty"`
// By is the build module and the SDK it was built with (sdkstep.By): the checker names it when a failure hides
// its cause.
By string `json:"by,omitempty"`
@@ -15,6 +15,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
@@ -100,9 +101,42 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
return nil, err
}
res.JudgedBy = RunnerInstalled
if level == LevelUnit {
if err := compareGenerated(tree, res); err != nil {
return nil, err
}
}
return res, nil
}
// compareGenerated regenerates the generated Go files of each contracts module in the tree (the tree's own manifest and
// each modules/<m>/module.json; CAT-BG5) and fails the level on any out of step, Why naming each path.
func compareGenerated(tree string, res *TestResult) error {
manifests, _ := filepath.Glob(filepath.Join(tree, "modules", "*", "module.json"))
if _, err := os.Stat(filepath.Join(tree, "module.json")); err == nil {
manifests = append([]string{filepath.Join(tree, "module.json")}, manifests...)
}
for _, mf := range manifests {
dir := filepath.Dir(mf)
paths, err := buildstep.OutOfStep(dir, buildstep.GeneratedRootGo)
if err != nil {
return err
}
rel, _ := filepath.Rel(tree, dir)
for _, p := range paths {
res.OutOfStep = append(res.OutOfStep, filepath.ToSlash(filepath.Join(rel, p)))
}
}
if len(res.OutOfStep) > 0 {
why := "generated files out of step with the seat generator (regenerate them): " + strings.Join(res.OutOfStep, ", ")
if res.Why != "" {
why = res.Why + "; " + why
}
res.Passed, res.Why = false, why
}
return nil
}
// testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the
// installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process.
func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) {
@@ -178,6 +178,40 @@ func TestUnitLevelOfAFixtureWithNoTestsSaysSo(t *testing.T) {
}
}
// CAT-BG5: a contracts module (a package artifact naming generated-from) whose generated Go files are out of step with
// the seat generator fails the unit level, each path named in its out-of-step and its why; its TypeScript is not this
// module's to compare.
func TestUnitLevelOfAContractsModuleOutOfStepFailsNamingEachPath(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "empty")
tree := filepath.Join(m.Work, ws, "tree")
for p, text := range map[string]string{
"module.json": `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}`,
"seats/index.json": `{"format":1,"seats":["one-store"]}`,
"seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`,
"go/go.mod": "module example.org/c/go\n",
"go/stray.go": "package stray\n",
"ts/package.json": `{"name":"@example/c"}`,
"ts/stray.ts": "// not this module's\n",
} {
full := filepath.Join(tree, filepath.FromSlash(p))
_ = os.MkdirAll(filepath.Dir(full), 0o755)
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
res, st := runUnit(t, m, states, ws)
stray := false
for _, p := range res.OutOfStep {
stray = stray || p == "go/stray.go"
if !strings.HasPrefix(p, "go/") {
t.Errorf("%s is not under the Go root", p)
}
}
if st.State != delivery.StepPassed || res.Passed || !stray || !strings.Contains(res.Why, "go/stray.go") {
t.Errorf("result = %+v (%s %s)", res, st.State, st.Reason)
}
}
// A package that does not build fails the level with the compiler's words.
func TestUnitLevelOfAPackageThatDoesNotBuildFailsWithTheCompilersLine(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "broken")