2026-04-11 00:17:10 -03:00
|
|
|
package testing_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"go/ast"
|
|
|
|
|
"go/parser"
|
|
|
|
|
"go/token"
|
|
|
|
|
"math"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"path/filepath"
|
2026-04-17 04:31:21 -03:00
|
|
|
"regexp"
|
2026-04-11 00:17:10 -03:00
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type coveragePolicy struct {
|
|
|
|
|
TargetPercent float64 `json:"target_percent"`
|
|
|
|
|
Files map[string]float64 `json:"files"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSourceFileLineLimit(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
var offenders []string
|
|
|
|
|
for _, relRoot := range []string{"cmd", "pkg", "scripts", "testing"} {
|
|
|
|
|
walkSourceFiles(t, filepath.Join(root, relRoot), func(path string, info os.DirEntry) error {
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
switch filepath.Ext(path) {
|
|
|
|
|
case ".go", ".py", ".sh":
|
|
|
|
|
lines, err := countLines(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if lines > 500 {
|
|
|
|
|
offenders = append(offenders, fmt.Sprintf("%s:%d", rel(root, path), lines))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if len(offenders) > 0 {
|
|
|
|
|
sort.Strings(offenders)
|
|
|
|
|
t.Fatalf("source files exceed 500 LOC: %s", strings.Join(offenders, ", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExportedDocs(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
var missing []string
|
|
|
|
|
fset := token.NewFileSet()
|
|
|
|
|
walkSourceFiles(t, root, func(path string, info os.DirEntry) error {
|
|
|
|
|
if info.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasPrefix(rel(root, path), "cmd/") && !strings.HasPrefix(rel(root, path), "pkg/") {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, decl := range file.Decls {
|
|
|
|
|
switch d := decl.(type) {
|
|
|
|
|
case *ast.FuncDecl:
|
|
|
|
|
if d.Name.IsExported() && !hasUsefulDoc(d.Doc, d.Name.Name) {
|
|
|
|
|
missing = append(missing, fmt.Sprintf("%s:%s", rel(root, path), d.Name.Name))
|
|
|
|
|
}
|
|
|
|
|
case *ast.GenDecl:
|
|
|
|
|
for _, spec := range d.Specs {
|
|
|
|
|
switch s := spec.(type) {
|
|
|
|
|
case *ast.TypeSpec:
|
|
|
|
|
if s.Name.IsExported() && !hasUsefulDoc(d.Doc, s.Name.Name) {
|
|
|
|
|
missing = append(missing, fmt.Sprintf("%s:%s", rel(root, path), s.Name.Name))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
if len(missing) > 0 {
|
|
|
|
|
sort.Strings(missing)
|
|
|
|
|
t.Fatalf("exported declarations without useful docs: %s", strings.Join(missing, ", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoFmtAndVet(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
gofmt := exec.Command("gofmt", "-l", "cmd", "pkg", "testing")
|
|
|
|
|
gofmt.Dir = root
|
|
|
|
|
out, err := gofmt.CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("gofmt check failed: %v\n%s", err, out)
|
|
|
|
|
}
|
|
|
|
|
if trimmed := strings.TrimSpace(string(out)); trimmed != "" {
|
|
|
|
|
t.Fatalf("gofmt -l reported files:\n%s", trimmed)
|
|
|
|
|
}
|
|
|
|
|
vet := exec.Command("go", "vet", "./...")
|
|
|
|
|
vet.Dir = root
|
|
|
|
|
out, err = vet.CombinedOutput()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("go vet failed: %v\n%s", err, out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCoveragePolicy(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
coveragePath := filepath.Join(root, "build", "coverage.out")
|
2026-04-11 03:12:59 -03:00
|
|
|
if err := os.MkdirAll(filepath.Dir(coveragePath), 0o755); err != nil {
|
|
|
|
|
t.Fatalf("create coverage dir: %v", err)
|
|
|
|
|
}
|
|
|
|
|
useExisting := os.Getenv("METIS_USE_EXISTING_COVERAGE") == "1"
|
|
|
|
|
if !useExisting {
|
|
|
|
|
if err := os.Remove(coveragePath); err != nil && !os.IsNotExist(err) {
|
|
|
|
|
t.Fatalf("clear stale coverage profile: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-11 00:17:10 -03:00
|
|
|
if _, err := os.Stat(coveragePath); err != nil {
|
2026-04-11 03:12:59 -03:00
|
|
|
if !os.IsNotExist(err) {
|
|
|
|
|
t.Fatalf("check coverage profile: %v", err)
|
|
|
|
|
}
|
|
|
|
|
cmd := exec.Command("go", "test", "-count=1", "./...", "-coverprofile=build/coverage.out")
|
2026-04-11 00:17:10 -03:00
|
|
|
cmd.Dir = root
|
|
|
|
|
out, runErr := cmd.CombinedOutput()
|
|
|
|
|
if runErr != nil {
|
|
|
|
|
t.Fatalf("root coverage run failed: %v\n%s", runErr, out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
policyPath := filepath.Join(root, "testing", "coverage_policy.json")
|
|
|
|
|
policy := loadCoveragePolicy(t, policyPath)
|
|
|
|
|
actual := readCoverageProfile(t, coveragePath)
|
|
|
|
|
var regressions []string
|
|
|
|
|
for file, min := range policy.Files {
|
|
|
|
|
got, ok := actual[file]
|
|
|
|
|
if !ok {
|
|
|
|
|
regressions = append(regressions, fmt.Sprintf("%s missing from coverage", file))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if got+0.05 < min {
|
|
|
|
|
regressions = append(regressions, fmt.Sprintf("%s %.1f < %.1f", file, got, min))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(regressions) > 0 {
|
|
|
|
|
sort.Strings(regressions)
|
|
|
|
|
t.Fatalf("coverage regressed: %s", strings.Join(regressions, ", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 04:31:21 -03:00
|
|
|
func TestStructureHygiene(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
genericNames := map[string]struct{}{
|
|
|
|
|
"tmp": {},
|
|
|
|
|
"temp": {},
|
|
|
|
|
"foo": {},
|
|
|
|
|
"bar": {},
|
|
|
|
|
"baz": {},
|
|
|
|
|
"misc": {},
|
|
|
|
|
"new": {},
|
|
|
|
|
"old": {},
|
|
|
|
|
"final": {},
|
|
|
|
|
"wip": {},
|
|
|
|
|
}
|
|
|
|
|
maxDepth := 8
|
|
|
|
|
|
|
|
|
|
var violations []string
|
|
|
|
|
for _, relRoot := range []string{"cmd", "pkg", "scripts", "testing"} {
|
|
|
|
|
base := filepath.Join(root, relRoot)
|
|
|
|
|
walkSourceFiles(t, base, func(path string, info os.DirEntry) error {
|
|
|
|
|
if info.IsDir() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
switch filepath.Ext(path) {
|
|
|
|
|
case ".go", ".py", ".sh":
|
|
|
|
|
default:
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
relative := rel(root, path)
|
|
|
|
|
depth := len(strings.Split(relative, "/"))
|
|
|
|
|
if depth > maxDepth {
|
|
|
|
|
violations = append(violations, fmt.Sprintf("%s: depth %d > %d", relative, depth, maxDepth))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
baseName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
|
|
|
for _, tok := range strings.FieldsFunc(baseName, func(r rune) bool {
|
|
|
|
|
return r == '_' || r == '-'
|
|
|
|
|
}) {
|
|
|
|
|
if _, found := genericNames[strings.ToLower(tok)]; found {
|
|
|
|
|
violations = append(violations, fmt.Sprintf("%s: non-descriptive token %q", relative, tok))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if len(violations) > 0 {
|
|
|
|
|
sort.Strings(violations)
|
|
|
|
|
t.Fatalf("structure hygiene violations: %s", strings.Join(violations, ", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCodeSmellContracts(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
patterns := []struct {
|
|
|
|
|
re *regexp.Regexp
|
|
|
|
|
scope []string
|
|
|
|
|
message string
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
re: regexp.MustCompile(`\bpanic\(`),
|
|
|
|
|
scope: []string{"cmd", "pkg"},
|
|
|
|
|
message: "avoid panic in production Go code",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
re: regexp.MustCompile(`\blog\.Fatalf\(`),
|
|
|
|
|
scope: []string{"pkg"},
|
|
|
|
|
message: "avoid log.Fatalf in pkg code",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
re: regexp.MustCompile(`\bfmt\.Print(f|ln)?\(`),
|
|
|
|
|
scope: []string{"pkg"},
|
|
|
|
|
message: "avoid fmt.Print* in pkg code",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type hit struct {
|
|
|
|
|
path string
|
|
|
|
|
line int
|
|
|
|
|
text string
|
|
|
|
|
msg string
|
|
|
|
|
}
|
|
|
|
|
var hits []hit
|
|
|
|
|
for _, rule := range patterns {
|
|
|
|
|
for _, relRoot := range rule.scope {
|
|
|
|
|
base := filepath.Join(root, relRoot)
|
|
|
|
|
walkSourceFiles(t, base, func(path string, info os.DirEntry) error {
|
|
|
|
|
if info.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
file, err := os.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer file.Close()
|
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
|
|
|
lineNo := 0
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
lineNo++
|
|
|
|
|
line := scanner.Text()
|
|
|
|
|
if rule.re.MatchString(line) {
|
|
|
|
|
hits = append(hits, hit{
|
|
|
|
|
path: rel(root, path),
|
|
|
|
|
line: lineNo,
|
|
|
|
|
text: strings.TrimSpace(line),
|
|
|
|
|
msg: rule.message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return scanner.Err()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(hits) > 0 {
|
|
|
|
|
sort.Slice(hits, func(i, j int) bool {
|
|
|
|
|
if hits[i].path == hits[j].path {
|
|
|
|
|
return hits[i].line < hits[j].line
|
|
|
|
|
}
|
|
|
|
|
return hits[i].path < hits[j].path
|
|
|
|
|
})
|
|
|
|
|
lines := make([]string, 0, len(hits))
|
|
|
|
|
for _, h := range hits {
|
|
|
|
|
lines = append(lines, fmt.Sprintf("%s:%d (%s) %s", h.path, h.line, h.msg, h.text))
|
|
|
|
|
}
|
|
|
|
|
t.Fatalf("code smell violations: %s", strings.Join(lines, ", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 00:17:10 -03:00
|
|
|
func countLines(path string) (int, error) {
|
|
|
|
|
f, err := os.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
s := bufio.NewScanner(f)
|
|
|
|
|
count := 0
|
|
|
|
|
for s.Scan() {
|
|
|
|
|
count++
|
|
|
|
|
}
|
|
|
|
|
return count, s.Err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func rel(root, path string) string {
|
|
|
|
|
out, err := filepath.Rel(root, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
return filepath.ToSlash(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hasUsefulDoc(comment *ast.CommentGroup, name string) bool {
|
|
|
|
|
if comment == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
text := strings.TrimSpace(comment.Text())
|
|
|
|
|
if text == "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if len(strings.Fields(text)) < 4 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.Contains(strings.ToLower(text), strings.ToLower(name[:1])) || len(text) > len(name)+12
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadCoveragePolicy(t *testing.T, path string) coveragePolicy {
|
|
|
|
|
t.Helper()
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var policy coveragePolicy
|
|
|
|
|
if err := json.Unmarshal(data, &policy); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if policy.TargetPercent == 0 {
|
|
|
|
|
policy.TargetPercent = 95
|
|
|
|
|
}
|
|
|
|
|
if policy.Files == nil {
|
|
|
|
|
policy.Files = map[string]float64{}
|
|
|
|
|
}
|
|
|
|
|
return policy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func readCoverageProfile(t *testing.T, path string) map[string]float64 {
|
|
|
|
|
t.Helper()
|
|
|
|
|
f, err := os.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
stats := map[string]struct{ covered, total int }{}
|
|
|
|
|
s := bufio.NewScanner(f)
|
|
|
|
|
for s.Scan() {
|
|
|
|
|
line := strings.TrimSpace(s.Text())
|
|
|
|
|
if line == "" || strings.HasPrefix(line, "mode:") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Fields(line)
|
|
|
|
|
if len(parts) != 3 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
file := strings.SplitN(parts[0], ":", 2)[0]
|
|
|
|
|
stmts, err := strconv.Atoi(parts[1])
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
count, err := strconv.Atoi(parts[2])
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
entry := stats[file]
|
|
|
|
|
entry.total += stmts
|
|
|
|
|
if count > 0 {
|
|
|
|
|
entry.covered += stmts
|
|
|
|
|
}
|
|
|
|
|
stats[file] = entry
|
|
|
|
|
}
|
|
|
|
|
if err := s.Err(); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
out := map[string]float64{}
|
|
|
|
|
for file, stat := range stats {
|
|
|
|
|
if stat.total == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
out[file] = math.Round((float64(stat.covered)/float64(stat.total))*1000) / 10
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|