2026-04-08 23:52:29 -03:00
|
|
|
package hygiene
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-10 16:55:27 -03:00
|
|
|
"bufio"
|
2026-04-08 23:52:29 -03:00
|
|
|
"go/ast"
|
|
|
|
|
"go/parser"
|
|
|
|
|
"go/token"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"runtime"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const maxGoFileLOC = 500
|
|
|
|
|
|
|
|
|
|
var goFileNamePattern = regexp.MustCompile(`^[a-z0-9]+(_[a-z0-9]+)*(_test)?\.go$`)
|
2026-04-22 05:06:21 -03:00
|
|
|
var genericFileNameTokens = map[string]struct{}{
|
|
|
|
|
"chunk": {},
|
|
|
|
|
"part": {},
|
|
|
|
|
"piece": {},
|
|
|
|
|
"split": {},
|
|
|
|
|
}
|
2026-04-08 23:52:29 -03:00
|
|
|
|
|
|
|
|
func repoRoot(tb testing.TB) string {
|
|
|
|
|
tb.Helper()
|
|
|
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
|
|
|
if !ok {
|
|
|
|
|
tb.Fatalf("unable to resolve caller path")
|
|
|
|
|
}
|
|
|
|
|
return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func collectGoFiles(tb testing.TB, roots ...string) []string {
|
|
|
|
|
tb.Helper()
|
|
|
|
|
files := make([]string, 0, 128)
|
|
|
|
|
for _, root := range roots {
|
|
|
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if d.IsDir() {
|
|
|
|
|
switch d.Name() {
|
|
|
|
|
case ".git", "vendor", "dist", "artifacts", "testdata":
|
|
|
|
|
return filepath.SkipDir
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if strings.HasSuffix(path, ".go") {
|
|
|
|
|
files = append(files, path)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
tb.Fatalf("walk %s: %v", root, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return files
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestHygieneContracts runs one orchestration or CLI step.
|
|
|
|
|
// Signature: TestHygieneContracts(t *testing.T).
|
|
|
|
|
// Why: Enforces an explicit quality order: doc contracts first, then naming,
|
|
|
|
|
// then file-size constraints. That keeps maintainability rules deterministic.
|
|
|
|
|
func TestHygieneContracts(t *testing.T) {
|
|
|
|
|
root := repoRoot(t)
|
|
|
|
|
files := collectGoFiles(t, filepath.Join(root, "cmd"), filepath.Join(root, "internal"))
|
2026-04-22 05:06:21 -03:00
|
|
|
namingFiles := append([]string{}, files...)
|
|
|
|
|
namingFiles = append(namingFiles, collectGoFiles(t, filepath.Join(root, "testing"))...)
|
2026-04-08 23:52:29 -03:00
|
|
|
sort.Strings(files)
|
2026-04-22 05:06:21 -03:00
|
|
|
sort.Strings(namingFiles)
|
2026-04-08 23:52:29 -03:00
|
|
|
|
|
|
|
|
t.Run("doc_contract", func(t *testing.T) {
|
|
|
|
|
checkDocContracts(t, files)
|
|
|
|
|
})
|
|
|
|
|
t.Run("naming_contract", func(t *testing.T) {
|
2026-04-22 05:06:21 -03:00
|
|
|
checkNamingContracts(t, namingFiles)
|
2026-04-08 23:52:29 -03:00
|
|
|
})
|
|
|
|
|
t.Run("loc_limit", func(t *testing.T) {
|
|
|
|
|
checkFileLOCLimits(t, files)
|
|
|
|
|
})
|
2026-04-10 16:55:27 -03:00
|
|
|
t.Run("split_module_contract", func(t *testing.T) {
|
|
|
|
|
checkSplitModuleContract(t, root, files)
|
|
|
|
|
})
|
2026-04-08 23:52:29 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkDocContracts runs one orchestration or CLI step.
|
|
|
|
|
// Signature: checkDocContracts(t *testing.T, files []string).
|
|
|
|
|
// Why: The project requires function-level intent to be explicit so future edits
|
|
|
|
|
// preserve behavior and avoid reintroducing drill-time regressions.
|
|
|
|
|
func checkDocContracts(t *testing.T, files []string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
fset := token.NewFileSet()
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
af, err := parser.ParseFile(fset, file, nil, parser.ParseComments)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Errorf("parse %s: %v", file, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, decl := range af.Decls {
|
|
|
|
|
fn, ok := decl.(*ast.FuncDecl)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if fn.Doc == nil {
|
|
|
|
|
t.Errorf("%s: function %s is missing doc comment block", file, fn.Name.Name)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
doc := fn.Doc.Text()
|
|
|
|
|
if !strings.Contains(doc, "Signature:") {
|
|
|
|
|
t.Errorf("%s: function %s doc is missing Signature: contract", file, fn.Name.Name)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(doc, "Why:") {
|
|
|
|
|
t.Errorf("%s: function %s doc is missing Why: contract", file, fn.Name.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkNamingContracts runs one orchestration or CLI step.
|
|
|
|
|
// Signature: checkNamingContracts(t *testing.T, files []string).
|
|
|
|
|
// Why: File/module naming consistency is part of maintainability and helps new
|
|
|
|
|
// contributors find behavior quickly as the lab and codebase expand.
|
|
|
|
|
func checkNamingContracts(t *testing.T, files []string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
base := filepath.Base(file)
|
|
|
|
|
if !goFileNamePattern.MatchString(base) {
|
|
|
|
|
t.Errorf("%s: filename %q violates naming contract %s", file, base, goFileNamePattern.String())
|
|
|
|
|
}
|
2026-04-22 05:06:21 -03:00
|
|
|
for _, token := range filenameTokens(base) {
|
|
|
|
|
if _, ok := genericFileNameTokens[token]; ok {
|
|
|
|
|
t.Errorf("%s: filename %q uses generic split-file token %q", file, base, token)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 23:52:29 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:06:21 -03:00
|
|
|
func filenameTokens(name string) []string {
|
|
|
|
|
trimmed := strings.TrimSuffix(strings.TrimSuffix(name, ".go"), "_test")
|
|
|
|
|
return strings.Split(trimmed, "_")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 23:52:29 -03:00
|
|
|
// checkFileLOCLimits runs one orchestration or CLI step.
|
|
|
|
|
// Signature: checkFileLOCLimits(t *testing.T, files []string).
|
|
|
|
|
// Why: A strict LOC cap forces focused files and keeps refactors manageable.
|
|
|
|
|
func checkFileLOCLimits(t *testing.T, files []string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
data, err := os.ReadFile(file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Errorf("read %s: %v", file, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
lineCount := strings.Count(string(data), "\n") + 1
|
|
|
|
|
if lineCount > maxGoFileLOC {
|
|
|
|
|
t.Errorf("%s has %d lines, exceeds %d", file, lineCount, maxGoFileLOC)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-10 16:55:27 -03:00
|
|
|
|
|
|
|
|
// checkSplitModuleContract runs one orchestration or CLI step.
|
|
|
|
|
// Signature: checkSplitModuleContract(t *testing.T, root string, files []string).
|
|
|
|
|
// Why: The long-term test layout requirement is top-level `testing/`; this
|
|
|
|
|
// guard freezes the remaining in-tree baseline so new root-module tests cannot
|
|
|
|
|
// slip in while the migration continues incrementally and safely.
|
|
|
|
|
func checkSplitModuleContract(t *testing.T, root string, files []string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
allowlistPath := filepath.Join(root, "testing", "hygiene", "in_tree_test_allowlist.txt")
|
|
|
|
|
allowed := loadInTreeTestAllowlist(t, allowlistPath)
|
|
|
|
|
actual := make([]string, 0, len(files))
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
if !strings.HasSuffix(file, "_test.go") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
actual = append(actual, mustRepoRelativePath(t, root, file))
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(actual)
|
|
|
|
|
|
|
|
|
|
allowedSet := make(map[string]struct{}, len(allowed))
|
|
|
|
|
for _, file := range allowed {
|
|
|
|
|
allowedSet[file] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
actualSet := make(map[string]struct{}, len(actual))
|
|
|
|
|
for _, file := range actual {
|
|
|
|
|
actualSet[file] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
unexpected := make([]string, 0)
|
|
|
|
|
for _, file := range actual {
|
|
|
|
|
if _, ok := allowedSet[file]; !ok {
|
|
|
|
|
unexpected = append(unexpected, file)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
missing := make([]string, 0)
|
|
|
|
|
for _, file := range allowed {
|
|
|
|
|
if _, ok := actualSet[file]; !ok {
|
|
|
|
|
missing = append(missing, file)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(unexpected) == 0 && len(missing) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var report strings.Builder
|
|
|
|
|
report.WriteString("split-module contract mismatch\n")
|
|
|
|
|
if len(unexpected) > 0 {
|
|
|
|
|
report.WriteString("unexpected in-tree tests (move them under testing/ or explicitly rebaseline if this is an intentional migration checkpoint):\n")
|
|
|
|
|
for _, file := range unexpected {
|
|
|
|
|
report.WriteString("- " + file + "\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(missing) > 0 {
|
|
|
|
|
report.WriteString("stale allowlist entries (remove them from testing/hygiene/in_tree_test_allowlist.txt after a migration):\n")
|
|
|
|
|
for _, file := range missing {
|
|
|
|
|
report.WriteString("- " + file + "\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
t.Fatal(report.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// loadInTreeTestAllowlist runs one orchestration or CLI step.
|
|
|
|
|
// Signature: loadInTreeTestAllowlist(t *testing.T, path string) []string.
|
|
|
|
|
// Why: The split-module baseline needs one canonical source of truth so the
|
|
|
|
|
// gate can distinguish legacy in-tree tests from accidental new ones.
|
|
|
|
|
func loadInTreeTestAllowlist(t *testing.T, path string) []string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
f, err := os.Open(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("open in-tree test allowlist %s: %v", path, err)
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
entries := make([]string, 0, 64)
|
|
|
|
|
s := bufio.NewScanner(f)
|
|
|
|
|
for s.Scan() {
|
|
|
|
|
line := strings.TrimSpace(s.Text())
|
|
|
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
entries = append(entries, filepath.ToSlash(line))
|
|
|
|
|
}
|
|
|
|
|
if err := s.Err(); err != nil {
|
|
|
|
|
t.Fatalf("scan in-tree test allowlist %s: %v", path, err)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(entries)
|
|
|
|
|
return entries
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mustRepoRelativePath runs one orchestration or CLI step.
|
|
|
|
|
// Signature: mustRepoRelativePath(t *testing.T, root string, path string) string.
|
|
|
|
|
// Why: Relative slash-normalized paths keep split-module hygiene output stable
|
|
|
|
|
// across machines and make allowlist diffs easy to review.
|
|
|
|
|
func mustRepoRelativePath(t *testing.T, root string, path string) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
rel, err := filepath.Rel(root, path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("make %s relative to %s: %v", path, root, err)
|
|
|
|
|
}
|
|
|
|
|
return filepath.ToSlash(rel)
|
|
|
|
|
}
|