Files
mesh-catalog/modules/mesh-build-go/cmd/mesh-build-go/lint.go
T
jschoubben e8a953818c
mesh/delivery merged: source.main moved to novox/mesh-catalog@64530a8dcb08
mesh/delivery-group group fix/1901-failure-contract merged: every member is merged
mesh/check checking e8a953818c71 on 64530a8dcb08 | novox/mesh-catalog#516 in group fix/1901-failure-contract
Merge main into fix/1901-failure-contract: pin mesh-sdk a2759ec (the group's sdk head), the failure kit check's marker lists what it reads (issue 1936), and the registry test's password can no longer match a random workspace name (issue 1901)
2026-10-27 00:39:35 +01:00

176 lines
5.8 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"fmt"
"go/format"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"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, buildstep.ConsoleFailure(d, "gofmt", "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, buildstep.ConsoleFailure(d, name, r.Stderr+"\n"+r.Stdout))
}
}
return failed, nil
}
// lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930).
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) {
// The ask's phases say it (issue 1890): lint of the directories, then their tests.
buildstep.Phase(ctx, fmt.Sprintf("unit: lint of %d directories", len(dirs)))
defer buildstep.Phase(ctx, fmt.Sprintf("unit: tests of %d directories", len(dirs)))
type answer struct {
failed []Failure
err error
}
answers := make([]answer, len(dirs))
slots := make(chan struct{}, m.parallel())
var wg sync.WaitGroup
var crash any // a panic of a lint goes on in the caller, as a panic of a test run does
var crashMu sync.Mutex
for i, d := range dirs {
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
defer func() {
if p := recover(); p != nil {
crashMu.Lock()
crash = p
crashMu.Unlock()
}
}()
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d))
}()
}
wg.Wait()
if crash != nil {
panic(crash)
}
var failed []Failure
for _, a := range answers {
if a.err != nil {
return nil, a.err
}
failed = append(failed, a.failed...)
}
return failed, nil
}