From ecd60fe074340a8b5fedcfff41afb4fcac6d9d98 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 16:16:37 +0200 Subject: [PATCH] =?UTF-8?q?Run=20gofmt=20and=20go=20vet=20with=20every=20b?= =?UTF-8?q?uild=20tag=20in=20each=20directory=20the=20Go=20unit=20level=20?= =?UTF-8?q?runs,=20so=20what=20the=20repositories'=20scripts=20checked=20i?= =?UTF-8?q?s=20every=20repository's=20check=20(issue=201887=20D7-go,=20ADR?= =?UTF-8?q?=200529=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mesh-build-go/cmd/mesh-build-go/lint.go | 129 ++++++++++++++++++ .../cmd/mesh-build-go/lint_test.go | 86 ++++++++++++ .../mesh-build-go/cmd/mesh-build-go/test.go | 10 ++ .../cmd/mesh-build-go/test_test.go | 4 +- 4 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/lint.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/lint_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/lint.go b/modules/mesh-build-go/cmd/mesh-build-go/lint.go new file mode 100644 index 00000000..e493f12b --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/lint.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "go/format" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// **gofmt and go vet are the unit level's** (novox/hq ADR 0529 §1; issue 1887 D7-go): what every repository's script ran +// beside its tests, run for every repository by the build module that runs its unit level. In each go.mod directory the +// level runs, every Go file outside testdata and vendor must be as gofmt writes it, and `go vet ./...` must pass with no +// tag and with every build tag the directory's files name together (integration, isolation, ...), so code behind a tag +// nothing else compiles is vetted too. A failure is the level's, naming the files or vet's lines. + +// buildTag is one name in a //go:build line. +var buildTag = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_.]*`) + +// notATag are the names of a //go:build line that are no tag a run sets: the platforms, cgo, ignore, go versions. +var notATag = map[string]bool{"ignore": true, "cgo": true, "linux": true, "darwin": true, "windows": true, "freebsd": true, + "openbsd": true, "netbsd": true, "unix": true, "plan9": true, "js": true, "wasip1": true, "android": true, "ios": true, + "amd64": true, "arm64": true, "386": true, "arm": true, "riscv64": true, "wasm": true, "ppc64le": true, "s390x": true} + +// lintable walks a go.mod directory's own Go files: not those of testdata, vendor, or a directory holding a go.mod of its +// own (another directory of the level, linted there). +func lintable(dir string, each func(path string) error) error { + return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != dir { + switch d.Name() { + case "testdata", "vendor": + return filepath.SkipDir + } + if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { + return filepath.SkipDir + } + if strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + } + return nil + } + if strings.HasSuffix(path, ".go") { + return each(path) + } + return nil + }) +} + +// unformatted are the Go files of a directory gofmt would change, relative to the tree, and the build tags they name. +func unformatted(tree, d string) (files, tags []string, err error) { + seen := map[string]bool{} + err = lintable(filepath.Join(tree, d), func(path string) error { + src, err := os.ReadFile(path) + if err != nil { + return err + } + if out, ferr := format.Source(src); ferr != nil || !bytes.Equal(out, src) { + rel, _ := filepath.Rel(tree, path) + files = append(files, filepath.ToSlash(rel)) + } + scan := bufio.NewScanner(bytes.NewReader(src)) + for scan.Scan() { + line := strings.TrimSpace(scan.Text()) + if strings.HasPrefix(line, "package ") { + break + } + if expr, ok := strings.CutPrefix(line, "//go:build "); ok { + for _, tag := range buildTag.FindAllString(expr, -1) { + if !notATag[tag] && !strings.HasPrefix(tag, "go1") && !seen[tag] { + seen[tag] = true + tags = append(tags, tag) + } + } + } + } + return nil + }) + sort.Strings(files) + sort.Strings(tags) + return files, tags, err +} + +// lint is gofmt and go vet of one go.mod directory d of the tree, run with run in the directory's go environment; a +// failure of either is a Failure of the level naming what failed. +func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string, env []string) ([]Failure, error) { + files, tags, err := unformatted(tree, d) + if err != nil { + return nil, fmt.Errorf("reading the Go files of %s: %w", d, err) + } + var failed []Failure + if len(files) > 0 { + failed = append(failed, Failure{Package: d, Test: "gofmt", + Output: "not as gofmt writes them (run gofmt -w): " + strings.Join(files, ", ")}) + } + sets := [][]string{nil} + if len(tags) > 0 { + sets = append(sets, tags) + } + for _, set := range sets { + args := []string{"-C", filepath.Join(tree, d), "vet"} + name := "go vet" + if len(set) > 0 { + args = append(args, "-tags", strings.Join(set, ",")) + name += " -tags " + strings.Join(set, ",") + } + args = append(args, "./...") + r := run(ctx, env, "go", args...) + if r.Err != "" { + return nil, fmt.Errorf("%s in %s could not be run: %s", name, d, firstLine(r.Err+"\n"+r.Stderr)) + } + if r.Status != 0 { + failed = append(failed, Failure{Package: d, Test: name, Output: strings.TrimSpace(r.Stderr + "\n" + r.Stdout)}) + } + } + return failed, nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/lint_test.go b/modules/mesh-build-go/cmd/mesh-build-go/lint_test.go new file mode 100644 index 00000000..abf91574 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/lint_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + tree := t.TempDir() + for name, body := range files { + p := filepath.Join(tree, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return tree +} + +// 1887/D7-go, ADR 0529 §1: an unformatted file fails the unit level naming the file (testdata and vendor are left +// alone), and go vet runs with no tag and with every tag the files name together, integration and isolation included, so +// a vet error behind isolation fails the level naming the run. +func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) { + tree := writeTree(t, map[string]string{ + "go.mod": "module x\n\ngo 1.26\n", + "a.go": "package x\n\nfunc A() {}\n", + "b.go": "package x\nfunc B() {}\n", + "in_integration_test.go": "//go:build integration\n\npackage x\n", + "iso.go": "//go:build integration && isolation && linux\n\npackage x\n", + "testdata/ugly.go": "package ugly\n", + "vendor/v/v.go": "package v\n", + "nested/go.mod": "module x/nested\n", + "nested/n.go": "package n\n", + }) + var vetted []string + run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + line := strings.Join(args[2:], " ") + vetted = append(vetted, line) + if strings.Contains(line, "isolation") { + return forgekey.Ran{Status: 1, Stderr: "iso.go:3: unreachable code"} + } + return forgekey.Ran{} + } + failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil) + if err != nil { + t.Fatal(err) + } + if strings.Join(vetted, " | ") != "vet ./... | vet -tags integration,isolation ./..." { + t.Errorf("vet ran %v", vetted) + } + if len(failed) != 2 || failed[0].Test != "gofmt" || !strings.Contains(failed[0].Output, "b.go") || + strings.Contains(failed[0].Output, "ugly") || strings.Contains(failed[0].Output, "n.go") || strings.Contains(failed[0].Output, "v.go") { + t.Fatalf("failed = %+v, want gofmt naming b.go alone", failed) + } + if failed[1].Test != "go vet -tags integration,isolation" || !strings.Contains(failed[1].Output, "unreachable") { + t.Errorf("failed = %+v, want vet with both tags naming its line", failed[1]) + } + // A formatted directory that vets passes. + clean := writeTree(t, map[string]string{"go.mod": "module y\n", "a.go": "package y\n"}) + if failed, err := (&Module{}).lint(context.Background(), func(context.Context, []string, string, ...string) forgekey.Ran { return forgekey.Ran{} }, clean, ".", nil); err != nil || len(failed) != 0 { + t.Errorf("a clean directory: %+v, %v", failed, err) + } +} + +// 1887/D7-go: through the step, the unit level of a tree holding an unformatted file fails, naming the file. +func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "empty") + tree := filepath.Join(m.Work, ws, "tree") + for name, body := range map[string]string{"go.mod": "module x\n", "x.go": "package x\nfunc X() {}\n"} { + if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + res, _ := runUnit(t, m, states, ws) + if res.Passed || len(res.Failed) != 1 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "x.go") { + t.Fatalf("result = %+v, want the level failed naming x.go", res) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index c2b8167b..02a8a01d 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -253,6 +253,16 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR if o.wall { res.Elapsed = m.clock().Sub(began).Seconds() } + if o.level == LevelUnit { + // gofmt and go vet of each directory the level ran are the unit level's (ADR 0529 §1; issue 1887 D7-go). + for _, d := range todo { + failed, err := m.lint(ctx, run, tree, d, append(m.goEnvFor(filepath.Join(tree, d)), o.env...)) + if err != nil { + return nil, err + } + res.Failed = append(res.Failed, failed...) + } + } res.judge(o.bounds) return res, nil } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go index 39cab882..80bbe9b9 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go @@ -30,6 +30,8 @@ func capturedRunner(t *testing.T, captured string, calls *[]cmdRecord) forgekey. switch { case name == "git" && args[2] == "ls-files": return forgekey.Ran{Stdout: "go.mod\n"} + case name == "go" && len(args) > 2 && args[2] == "vet": + return forgekey.Ran{} // vet passes: the fixtures are vetted (lint_test.go) case name == "go": return forgekey.Ran{Stdout: string(raw), Status: status} } @@ -167,7 +169,7 @@ func TestUnitLevelRunsGoTestJsonInTheModulesDirectoryWithTheStepsEnvironment(t * var line string var env string for _, c := range *calls { - if strings.HasPrefix(c.line, "go ") { + if strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, " test ") { line, env = c.line, strings.Join(c.env, " ") } }