Function-level lint cannot see a god package. gocyclo at 10, funlen at 80, revive’s argument limits: all of those look inside one function. Two hundred methods of complexity 8 on one type is legal. After six months of agents on txn2/mcp-data-platform, Platform had 265 of them and gocyclo had never failed.
An agent rereads the tree at the start of every session, with a finite context window. A type that holds the whole application is the file it opens for every change, so every change is made while every other field and method on that type is in the prompt. The cheapest place to put the next feature is the same type as the last one. The next session copies that: another method on Platform, another file in the same package.
So I wrote tests that count fields, methods, package lines, public names, and first-party imports. The function-level leash from February keeps each new method under the cyclomatic and length caps. It does not fail when the two-hundred-and-sixty-sixth method lands on Platform.
§TL;DR
Per-function lint cannot see a god package. After six months of agents on
txn2/mcp-data-platform,make verifygrew a second layer: tests that count package lines, fields on a named type, first-party imports, unimported packages, and noops, plus tests that fail when the Makefile, CI, and CONTRIBUTING.md disagree on a version or a coverage floor, and when an integration-tagged test is not named*RealDB*. Seed each ceiling on today’s tree and ratchet down. Never raise a number to make a violation pass. Copy the listings into the directory that containsgo.mod. I ran them against a smallexample.com/billingmodule before pasting. They failed when I added an unimported package, a fifth field on a type capped at four, and an 80 incodecov.ymlagainst an 82 in the Makefile.The February configs and the go-leash skill still cover the function layer. This article is the tests that fire when a package outgrows what a session can read.
AI on a Leash Series | Previous: Complete Go Project Configuration is the function-level leash. The Pre-Commit Review Gate is the commit hook. This article is the package-size, import-graph, and pin-drift tests.
I have written about this a few times. Go’s constraints make the first draft better. Ralph’s Uncle argued for refining the toolchain instead of the prompt. The complete Go configuration was every file you need to start. The pre-commit review gate closed the loop between “tests passed” and “this is committed.” Those articles still apply. None of them fail when Platform grows a field.
Agents write the same god packages, unused packages, and extra imports humans do, only faster, and they do it while every per-function check stays green. Coverage at 82% does not care that the new table has no INSERT. The agent takes the cheapest path to green. A human does that at 4pm on a Friday. The agent never gets tired of taking it.
§What Actually Happened
txn2/mcp-data-platform is a Go MCP server that has been under continuous agent-assisted development for about six months. The make verify target is the restrictive gate. CLAUDE.md tells every session that “tested” means that target, not go test on one package. The gates in this article were added after the failures below, not designed on day one.
Four of those failures:
The Platform struct hit 82 fields and 265 methods. Every method was small enough for revive. The type was the entire application. We spent a stretch of issues decomposing Platform, froze the ceilings when that work ended, and ratcheted again when the call catalog folded three audit fields into one layer. The field count is 45. The method count is 212. Those numbers are constants in godobject_budget_test.go. Raising either one fails the test until you change the constant, and the failure text says not to.
A prompt-create path shipped a pq.Array(nil) into a NOT NULL column. Every sqlmock test passed. The mock does not enforce the schema. The defect needed a real Postgres, which is why make test-realdb exists and why a test whose name does not contain RealDB is now a failure.
Local gosec 2.26.1 dropped the G704 SSRF rule that CI’s pinned version still enforced. make verify was green. CI rejected the same diff with a real bug (PR #377, 2026-05-08). tools-check now refuses to run verify if the local binary is not the version CI pins.
An integration-tagged test that is not named *RealDB* runs nowhere automated. It compiles. It sits in the tree. It rots. That is worse than no test, because it looks like coverage of a path you do not have.
The suite is not finished. Allowlists still hold packages we have not split. The cohesion gate has a known blind spot, covered below. Mutation testing is too slow for the per-commit target and lives in make verify-release. The test files copy; the constants and allowlists do not. Copy the files, seed the numbers on your tree, and the same failures will fire: a type that keeps growing, a package nothing imports, an integration test CI never runs.
§A Ratchet Is Not a Target
A ratchet, here, is a ceiling that only moves down. Every structural gate in this article is seeded green against the tree you already have. You do not have to split the worst package before the gate can land. Run it with -v, set the constant to the printed count or one above, and the next added file or field fails.
Hitting the ceiling means split the package or move methods onto an owner type. Raising the constant to land a feature is how you get a 20,000-line package with a comment that says “temporary.”
A ceiling with slack is not a ratchet. If the type has 45 fields and the constant says 50, the next five fields are free. Set the constant to 45.
An allowlist entry has to expire. Every exemption carries a reason and an exit, and a test fails when the reason is no longer true: the package was split, the edge was removed, the second importer appeared. Otherwise the allowlist is a permanent pardon that nobody will revoke.
Agents treat a number they can edit as a suggestion. They treat a failing test they cannot silence as a wall. The no-lint-suppression rule is the same idea. //nolint is a developer decision, not an AI one. Raising maxPackageLOC to make TestPackageSizeBudget pass is the same cheat as adding //nolint.
§The Layers
The February article stacked static analysis, unit tests, integration tests, and mutation testing. That stack asks whether a function is correct. This stack asks whether a package still has one job, a type still has a bounded field count, and the import graph still matches the file you reviewed.
| Layer | What drifts | What cannot see it | What catches it |
|---|---|---|---|
| Function | complexity, dropped errors, races | human review at volume | golangci-lint, go test -race |
| Tests | tautology, untested new lines | line coverage alone | mutation, patch coverage |
| Wiring | packages and tables that never run | deadcode, the compiler | dead-package, noop, migration-consumer tests |
| Structure | god packages, god types, import spaghetti | gocyclo, funlen, file-level lint | size, field/method, import, cohesion gates |
| The gates themselves | version drift, orphaned tests, two coverage numbers | discipline | tools-check, pin tests, integration-name guard |
The rest of this article is the last three rows. Copy each listing into a *_test.go file in the directory that contains go.mod. Standard library only. The package name is ratchet_test, so go test -count=1 . runs them and nothing in pkg/ can import them.
If the module has no pkg/ or internal/ directory, change every walk root from []string{"pkg", "internal", "cmd"} to []string{"."}. That slice appears in TestPackageSizeBudget, walkGoFiles calls in TestPackageExportedSurfaceBudget, TestPublicSurfacePolicy, TestPackageImportRatchet, and TestNoDeadPackages.
§Shared Helpers
Put this file at the module root with the rest of the tests. Same package, same directory. The listings that follow call walkGoFiles, modulePath, skipDir, and mustRead from here. If you drop only one of the later files and skip this one, it will not compile.
// ratchet_helpers_test.go
package ratchet_test
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func modulePath(t *testing.T) string {
t.Helper()
f, err := os.Open("go.mod")
if err != nil {
t.Fatalf("open go.mod: %v", err)
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if rest, ok := strings.CutPrefix(line, "module "); ok {
return strings.TrimSpace(rest)
}
}
t.Fatal("go.mod has no module line")
return ""
}
func skipDir(name string) bool {
switch name {
case ".git", "vendor", "node_modules", "testdata", "dist", "build":
return true
}
return strings.HasPrefix(name, ".")
}
func walkGoFiles(t *testing.T, roots []string, includeTests bool, fn func(rel string, f *ast.File)) {
t.Helper()
fset := token.NewFileSet()
for _, root := range roots {
if _, err := os.Stat(root); os.IsNotExist(err) {
continue
}
err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() {
if skipDir(info.Name()) && path != root {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
if !includeTests && strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
file, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
return fmt.Errorf("parse %s: %w", path, err)
}
rel, err := filepath.Rel(".", path)
if err != nil {
return err
}
fn(filepath.ToSlash(rel), file)
return nil
})
if err != nil {
t.Fatal(err)
}
}
}
func pkgOf(rel string) string {
dir := filepath.ToSlash(filepath.Dir(rel))
if dir == "." {
return ""
}
return dir
}
var generatedRe = regexp.MustCompile(`^// Code generated .* DO NOT EDIT\.?$`)
func isGenerated(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
sc := bufio.NewScanner(f)
for i := 0; i < 20 && sc.Scan(); i++ {
if generatedRe.MatchString(strings.TrimSpace(sc.Text())) {
return true
}
}
return false
}
func mustRead(t *testing.T, rel string) string {
t.Helper()
b, err := os.ReadFile(rel)
if err != nil {
t.Fatalf("read %s: %v", rel, err)
}
return string(b)
}
Generated files are skipped by the canonical Code generated ... DO NOT EDIT. marker. swag’s slightly different header needs a second regexp. The production gate in mcp-data-platform has it because internal/apidocs/docs.go is 19,000 lines of embedded OpenAPI and would blow any line-count budget if counted as hand-written.
§Package Size
gocyclo, gocognit, funlen, and nestif all evaluate the inside of a function. They are the complexity linters enabled in the February golangci-lint config. A package assembled from two hundred functions of complexity 8 is legal. It is also the package you cannot review in one sitting, cannot summarize for the next session, and cannot split later without a dedicated issue.
That is the god-package. Agents build them because the cheapest way to add a feature is to put it next to the last feature. Humans do the same thing. The agent just does it every hour.
The fix is a ceiling on the package as a whole. Seed it on your current largest package. Run with -v to see the sizes. Then lower the constant when an extraction actually shrinks something. Line count here is file lines, including blanks and comments.
// package_budget_test.go
package ratchet_test
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// Seed just above today's largest package. Hitting the ceiling means
// decompose, not raise the number. Run with -v to see current sizes.
const (
maxPackageLOC = 400
maxPackageFiles = 8
)
func TestPackageSizeBudget(t *testing.T) {
type size struct{ loc, files int }
sizes := map[string]*size{}
for _, tree := range []string{"pkg", "internal", "cmd"} {
if _, err := os.Stat(tree); os.IsNotExist(err) {
continue
}
err := filepath.Walk(tree, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() {
if skipDir(info.Name()) && path != tree {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
if isGenerated(path) {
return nil
}
n, err := countLines(path)
if err != nil {
return err
}
dir := filepath.ToSlash(filepath.Dir(path))
if sizes[dir] == nil {
sizes[dir] = &size{}
}
sizes[dir].loc += n
sizes[dir].files++
return nil
})
if err != nil {
t.Fatal(err)
}
}
if len(sizes) == 0 {
t.Fatal("no packages found under pkg/, internal/, or cmd/")
}
var violations, measured []string
for pkg, s := range sizes {
measured = append(measured, fmt.Sprintf("%5d LOC %2d files %s", s.loc, s.files, pkg))
if s.loc > maxPackageLOC {
violations = append(violations, fmt.Sprintf(
"%s: %d LOC exceeds %d (decompose; do not raise the budget)", pkg, s.loc, maxPackageLOC))
}
if s.files > maxPackageFiles {
violations = append(violations, fmt.Sprintf(
"%s: %d files exceeds %d (decompose; do not raise the budget)", pkg, s.files, maxPackageFiles))
}
}
sort.Strings(measured)
t.Logf("package sizes:\n %s", strings.Join(measured, "\n "))
if len(violations) > 0 {
sort.Strings(violations)
t.Fatalf("package size budget exceeded:\n %s", strings.Join(violations, "\n "))
}
}
func countLines(path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
n := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
n++
}
return n, sc.Err()
}
On mcp-data-platform the pkg/ ceiling started at 13,000 and has been ratcheted down to 9,600 as extractions landed. internal/ got its own, tighter ceiling after we noticed the first walk was rooted at pkg/, so moving 12,000 lines into internal/platform had quietly taken them off the budget. A package too large to review in one sitting is too large under internal/ as well. That is why TestPackageSizeBudget walks both trees.
This gate is gameable. Split one package into three directories that still import each other and TestPackageSizeBudget goes green. TestPackageImportRatchet and TestPackageCohesion are there because of that.
§God Objects
Moving methods from platform.go into platform_foo.go shrinks a file and does nothing to type Platform struct. The agent will do this when you ask it to “decompose” without saying what you mean. TestPackageSizeBudget goes green. Platform still has every field and every method.
So we count the fields on the struct and the methods on the receiver. Both pointer and value receivers, or rewriting func (p *Platform) as func (p Platform) becomes an escape hatch. Grouped fields (a, b int) count as two. Embedded fields count as one.
// godobject_budget_test.go
package ratchet_test
import (
"fmt"
"go/ast"
"sort"
"strings"
"testing"
)
// Name the types that must not become god objects. Seed field and method
// ceilings at today's count, then ratchet down as you extract owners.
var godObjects = []struct {
dir string
typeName string
maxFields, maxMeths int
}{
{dir: "pkg/wire", typeName: "Service", maxFields: 4, maxMeths: 8},
}
func TestGodObjectBudget(t *testing.T) {
var violations []string
for _, spec := range godObjects {
fields, methods := countGodObject(t, spec.dir, spec.typeName)
t.Logf("%s.%s: %d fields, %d methods (ceilings %d / %d)",
spec.dir, spec.typeName, fields, methods, spec.maxFields, spec.maxMeths)
if fields > spec.maxFields {
violations = append(violations, fmt.Sprintf(
"%s.%s has %d fields, ceiling %d — move state onto an owner; do not raise the ceiling",
spec.dir, spec.typeName, fields, spec.maxFields))
}
if methods > spec.maxMeths {
violations = append(violations, fmt.Sprintf(
"%s.%s has %d methods, ceiling %d — move behavior onto an owner; do not raise the ceiling",
spec.dir, spec.typeName, methods, spec.maxMeths))
}
}
if len(violations) > 0 {
sort.Strings(violations)
t.Fatal(strings.Join(violations, "\n"))
}
}
func countGodObject(t *testing.T, dir, typeName string) (fields, methods int) {
t.Helper()
found := false
walkGoFiles(t, []string{dir}, false, func(_ string, f *ast.File) {
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if isRecv(d, typeName) {
methods++
}
case *ast.GenDecl:
if n, ok := structFields(d, typeName); ok {
fields = n
found = true
}
}
}
})
if !found {
t.Fatalf("did not find `type %s struct` in %s", typeName, dir)
}
return fields, methods
}
func isRecv(fn *ast.FuncDecl, typeName string) bool {
if fn.Recv == nil || len(fn.Recv.List) != 1 {
return false
}
recv := fn.Recv.List[0].Type
if star, ok := recv.(*ast.StarExpr); ok {
recv = star.X
}
ident, ok := recv.(*ast.Ident)
return ok && ident.Name == typeName
}
func structFields(gen *ast.GenDecl, typeName string) (int, bool) {
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok || ts.Name.Name != typeName {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
n := 0
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
n++
continue
}
n += len(field.Names)
}
return n, true
}
return 0, false
}
Change the godObjects row from pkg/wire / Service to the type that already has too many fields. Run go test -count=1 -v -run TestGodObjectBudget ., set maxFields and maxMeths to the printed counts, commit. The next field added after that fails the test.
On mcp-data-platform this is how 82 fields became 45: each extraction folded a cluster (indexqueue.Handle, portalstore.Handle, auditwiring.Layer) into one field and ratcheted maxPlatformFields in the same PR. One-line accessors that stay on Platform still count. That is fine. The test is not trying to drive the type to zero fields. Platform still holds db, config, the toolkit registry, and one handle per extracted subsystem. Driving it to zero deletes the type cmd/ constructs. The file header in godobject_budget_test.go states that the ceilings are a standing anti-regrowth invariant, because the next agent will otherwise keep extracting until cmd/ has nothing to construct.
§Exported Surface
Line count bounds how many lines a package has. The exported-surface budget bounds how many public names another module can import. Under pkg/ every exported identifier is a v1 compatibility promise. Agents export helpers because they might be useful. Six months later you cannot rename FormatTimestamp without a major version.
This one cannot be satisfied by splitting files. The only way under the budget is to unexport or move the name into internal/.
// exported_surface_test.go
package ratchet_test
import (
"fmt"
"go/ast"
"sort"
"strings"
"testing"
)
// Seed just above today's largest exported surface under pkg/.
const maxExportedSurface = 20
func TestPackageExportedSurfaceBudget(t *testing.T) {
counts := map[string]int{}
walkGoFiles(t, []string{"pkg"}, false, func(rel string, f *ast.File) {
dir := pkgOf(rel)
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if d.Recv == nil && d.Name.IsExported() {
counts[dir]++
}
case *ast.GenDecl:
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
if s.Name.IsExported() {
counts[dir]++
}
case *ast.ValueSpec:
for _, name := range s.Names {
if name.IsExported() {
counts[dir]++
}
}
}
}
}
}
})
if len(counts) == 0 {
t.Fatal("no packages under pkg/")
}
var violations, measured []string
for pkg, n := range counts {
measured = append(measured, fmt.Sprintf("%4d %s", n, pkg))
if n > maxExportedSurface {
violations = append(violations, fmt.Sprintf(
"%s: %d exported identifiers exceeds %d (shrink the public API)", pkg, n, maxExportedSurface))
}
}
sort.Sort(sort.Reverse(sort.StringSlice(measured)))
t.Logf("exported surfaces (ceiling %d):\n %s", maxExportedSurface, strings.Join(measured, "\n "))
if len(violations) > 0 {
t.Fatalf("exported-surface budget exceeded:\n %s", strings.Join(violations, "\n "))
}
}
Methods and struct fields are not counted. They live on the type, not in the package scope. The metric is how many names import "your/module/pkg/foo" can see at the top level: exported funcs, types, vars, and consts. We do not apply this gate to internal/: an exported name there is visible inside the module only, and TestPackageSizeBudget already fails if an internal package exceeds its line ceiling.
§Stability Policy
Counting public names is not the same as deciding which packages should be public. Go at v1, with no /vN suffix, promises compatibility across everything importable. A docs page that names a “supported surface” does not enforce it. Every package added under pkg/ silently took on that promise, and twenty-two of them had one importer: cmd/ or the type cmd/ constructs.
The rule: a package under pkg/ must be on the documented supported surface, or have more than one first-party importer. One importer means the package exists to serve exactly one caller. That is an internal helper wearing a public path. It belongs under internal/, where Go itself refuses the import from outside the module.
Two importers is the threshold rather than one because a package genuinely shared between subsystems is doing integration work even when no external consumer has shown up yet. Forcing that into internal/ would be the wrong call.
// stability_policy_test.go
package ratchet_test
import (
"fmt"
"go/ast"
"sort"
"strings"
"testing"
)
// Packages a library consumer is allowed to import. Everything else under
// pkg/ needs either two first-party importers or an allowlist entry.
var supportedSurface = map[string]bool{
"pkg/invoice": true,
"pkg/wire": true,
}
type stabilityExemption struct {
why, exit string
}
func stabilityAllowlist() map[string]stabilityExemption {
return map[string]stabilityExemption{
// "pkg/postgres": {why: "reference store a consumer constructs", exit: "retire if injection goes away"},
}
}
func TestPublicSurfacePolicy(t *testing.T) {
mod := modulePath(t)
pkgs := map[string]bool{}
walkGoFiles(t, []string{"pkg"}, false, func(rel string, _ *ast.File) {
if dir := pkgOf(rel); dir != "" {
pkgs[dir] = true
}
})
if len(pkgs) == 0 {
t.Fatal("no packages under pkg/")
}
importers := map[string]map[string]bool{}
walkGoFiles(t, []string{"pkg", "cmd", "internal"}, false, func(rel string, f *ast.File) {
from := pkgOf(rel)
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if path != mod && !strings.HasPrefix(path, mod+"/") {
continue
}
to := strings.TrimPrefix(path, mod+"/")
if to == "" {
to = "."
}
if !strings.HasPrefix(to, "pkg/") || to == from {
continue
}
if importers[to] == nil {
importers[to] = map[string]bool{}
}
importers[to][from] = true
}
})
allow := stabilityAllowlist()
var violations, stale []string
for rel := range pkgs {
n := len(importers[rel])
offends := !supportedSurface[rel] && n <= 1
_, exempt := allow[rel]
switch {
case offends && !exempt:
names := make([]string, 0, n)
for k := range importers[rel] {
names = append(names, k)
}
sort.Strings(names)
violations = append(violations, fmt.Sprintf(
"%s: outside the supported surface with %d importer(s) (%s) — move to internal/ or promote it",
rel, n, strings.Join(names, ", ")))
case !offends && exempt:
stale = append(stale, rel)
}
}
for rel := range allow {
if !pkgs[rel] {
stale = append(stale, rel+" (no such package)")
}
}
if len(violations) > 0 {
sort.Strings(violations)
t.Errorf("stability policy:\n %s", strings.Join(violations, "\n "))
}
if len(stale) > 0 {
sort.Strings(stale)
t.Errorf("stale stabilityAllowlist entries (delete them): %s", strings.Join(stale, ", "))
}
}
func TestStabilityExemptionsAreJustified(t *testing.T) {
for pkg, ex := range stabilityAllowlist() {
if strings.TrimSpace(ex.why) == "" {
t.Errorf("%s: exemption needs a why", pkg)
}
if strings.TrimSpace(ex.exit) == "" {
t.Errorf("%s: exemption needs an exit condition", pkg)
}
}
}
Copy the package list from your README or stability doc into supportedSurface. A stale allowlist entry is a failure: the package gained a second importer, moved into supportedSurface, or left pkg/. Delete the entry so the list shrinks.
The production file is pkg_stability_policy_test.go. Most of the twenty-two moves were one-importer packages sitting on a silent v1 promise they were never meant to make. The remaining allowlist entries are reference implementations a library consumer constructs and injects (WithSessionStore, WithQueryProvider, and the rest). Those stay under pkg/ on purpose. Each entry has a why and an exit; TestStabilityExemptionsAreJustified fails if either is empty.
§Import Ratchet
The compiler forbids import cycles. It does not forbid a toolkit importing the admin API, or a provider reaching up into the facade, or two sibling packages slowly becoming one package with two paths. depguard in .golangci.yml is the direction half: declare the layers that must not depend up. The ratchet is the coverage half: freeze the entire first-party edge set in a checked-in file, and fail on any addition or any stale entry.
The stale-entry half surprised me. An allowlist that only grows is how a coupling you deliberately removed gets pre-approved the next time an agent reaches for it. Equality in both directions makes the file a mirror of the graph, not a suggestion.
// import_ratchet_test.go
package ratchet_test
import (
"flag"
"go/ast"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
var updateImports = flag.Bool("update-imports", false,
"rewrite testdata/allowed_internal_imports.txt from the current graph")
const allowedImportsPath = "testdata/allowed_internal_imports.txt"
func TestPackageImportRatchet(t *testing.T) {
mod := modulePath(t)
current := firstPartyEdges(t, mod)
if len(current) == 0 {
t.Fatal("first-party import graph is empty — the ratchet cannot bite")
}
if *updateImports {
if err := os.MkdirAll(filepath.Dir(allowedImportsPath), 0o750); err != nil {
t.Fatal(err)
}
body := []byte(strings.Join(current, "\n") + "\n")
if err := os.WriteFile(allowedImportsPath, body, 0o600); err != nil {
t.Fatal(err)
}
t.Logf("wrote %d edges to %s", len(current), allowedImportsPath)
return
}
raw, err := os.ReadFile(allowedImportsPath)
if err != nil {
t.Fatalf("read %s: %v (generate with: go test -run TestPackageImportRatchet -args -update-imports)",
allowedImportsPath, err)
}
allowed := map[string]bool{}
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimSpace(line); line != "" {
allowed[line] = true
}
}
var added, stale []string
present := map[string]bool{}
for _, e := range current {
present[e] = true
if !allowed[e] {
added = append(added, e)
}
}
for e := range allowed {
if !present[e] {
stale = append(stale, e)
}
}
sort.Strings(stale)
if len(added) > 0 {
t.Errorf("new first-party import(s) not in the allowlist. If intentional, regenerate with -args -update-imports and justify in the PR:\n %s",
strings.Join(added, "\n "))
}
if len(stale) > 0 {
t.Errorf("allowlisted import(s) no longer exist. A stale entry pre-approves reintroducing removed coupling. Regenerate:\n %s",
strings.Join(stale, "\n "))
}
}
func firstPartyEdges(t *testing.T, mod string) []string {
t.Helper()
seen := map[string]bool{}
walkGoFiles(t, []string{"pkg", "cmd", "internal"}, false, func(rel string, f *ast.File) {
from := pkgOf(rel)
if from == "" {
return
}
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if path == mod || strings.HasPrefix(path, mod+"/") {
to := strings.TrimPrefix(path, mod+"/")
seen[from+" -> "+to] = true
}
}
})
edges := make([]string, 0, len(seen))
for e := range seen {
edges = append(edges, e)
}
sort.Strings(edges)
return edges
}
Seed it:
go test -run TestPackageImportRatchet . -args -update-imports
git add testdata/allowed_internal_imports.txt
After that, a new import of a first-party package fails TestPackageImportRatchet until you regenerate testdata/allowed_internal_imports.txt and commit it. The PR diff of that file is what the reviewer reads. The production version uses golang.org/x/tools/go/packages so build tags and cgo resolve the way the compiler does. The stdlib walk above misses files the compiler includes under a build tag. Use go/packages when that matters. It is an extra module.
depguard still earns its keep for the rules you can state in English: cmd/ is a sink, providers do not depend up, leaf utilities import nothing first-party. Probe a new rule with a blank import (import _ "your/module/pkg/admin") so the package still type-checks. An import cycle makes depguard report nothing, which looks identical to a rule that does not fire.
§Cohesion, Briefly
Size is gamed by shattering. The other failure is a package whose declarations form two islands that never mention each other: two packages sharing one import path. We build the declaration reference graph (package-level funcs, types, vars, consts; edges to every package-level name they use) and fail when there is more than one connected component of five or more names.
That test is long and uses go/types. I am not pasting it. The production file is pkg_relationship_test.go. Two things about it matter more than the graph walk:
The allowlist is required to open with N clusters: and name the exit (extract the timeseries query types into pkg/audit/analytics). A second test checks that N still matches what the gate measures. Split the package partway and the justification goes stale, which is a failure, not a comment you forgot to edit.
The shared-identifier edge has a false negative: two unrelated islands that both touch one logger var look connected. We measured a refinement that discounted a connection surviving through a single shared name, and it flagged 50 of 158 green packages, almost all of them because the cut was the package’s central type (Handler, Store, Config). A rule that fails 50 of 158 green packages gets turned off. A green TestPackageCohesion does not prove the package is one job. depguard and TestPackageImportRatchet cannot be satisfied by moving lines around. Cohesion can.
§Vaporware
deadcode finds unreachable functions. Agents produce a whole package with its own tests, never imported by cmd/ or anything cmd/ reaches. Or a migration that creates widget_events and no INSERT. Or an interface whose only implementation is NoopWriter.
The last one still bothers me. A noop satisfies the compiler, TestNoDeadPackages, the coverage report, and the wiring in cmd/. The feature is listed in the API. It does nothing. We spent six weeks building handlers and stores and admin APIs around an upstream write that the dependency could not perform.
// vaporware_test.go
package ratchet_test
import (
"go/ast"
"os"
"regexp"
"strings"
"testing"
)
func TestNoDeadPackages(t *testing.T) {
mod := modulePath(t)
pkgs := map[string]bool{}
walkGoFiles(t, []string{"pkg"}, false, func(rel string, _ *ast.File) {
dir := pkgOf(rel)
if dir != "" {
pkgs[mod+"/"+dir] = false
}
})
if len(pkgs) == 0 {
t.Fatal("no packages under pkg/")
}
walkGoFiles(t, []string{"pkg", "cmd", "internal"}, false, func(_ string, f *ast.File) {
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if _, ok := pkgs[path]; ok {
pkgs[path] = true
}
}
})
for path, imported := range pkgs {
if !imported {
t.Errorf("package %q is never imported by non-test code — wire it in or delete it", path)
}
}
}
func TestNoopOnlyInterfaces(t *testing.T) {
implRe := regexp.MustCompile(`var\s+_\s+(\S+)\s*=\s*\(\*(\w+)\)\(nil\)`)
type impl struct{ iface, typ string }
var found []impl
walkGoFiles(t, []string{"pkg", "internal"}, false, func(rel string, _ *ast.File) {
src, err := os.ReadFile(rel)
if err != nil {
t.Fatal(err)
}
for _, m := range implRe.FindAllStringSubmatch(string(src), -1) {
found = append(found, impl{iface: m[1], typ: m[2]})
}
})
if len(found) == 0 {
t.Skip("no compile-time interface assertions found")
}
byIface := map[string][]string{}
for _, im := range found {
byIface[im.iface] = append(byIface[im.iface], im.typ)
}
for iface, types := range byIface {
hasNoop, hasReal := false, false
for _, name := range types {
if strings.Contains(strings.ToLower(name), "noop") {
hasNoop = true
} else {
hasReal = true
}
}
if hasNoop && !hasReal {
t.Errorf("interface %s has only noop implementation(s) %v — a real one is required, or remove the feature", iface, types)
}
}
}
TestNoDeadPackages only looks at pkg/. A package under internal/ that nothing imports is still dead, but it is also invisible to other modules, and deadcode will usually catch the unused funcs. The dangerous case is the public-looking package that an agent built “for later.”
For tables, extract every live CREATE TABLE from up-migrations (minus later DROP, accounting for RENAME) and require the name to appear in a DML statement in non-test, non-migration Go. The production test is TestMigrationTablesHaveConsumers in pkg/database/migrate. Walk pkg/ and internal/, or the first store you move out of pkg/ fails that test.
The CLAUDE.md rule that goes with these tests: do not write a store, a handler, and an admin API for a write you have not confirmed the upstream can perform. Open the dependency first. If it cannot do the write, stop. A green suite around a noop is how six weeks disappear.
§Tests That Never Run
//go:build integration does not mean CI runs the test. CI on this project runs those tests only as go test -tags=integration -run RealDB ./.... A test named TestPromptCreateRoundTrip with that build tag compiles and is never executed. TestIntegrationTestsAreExecuted walks the tree and fails unless the name contains RealDB or the path is on an allowlist with a one-line reason (test/e2e needs a live DataHub; a specific Ollama test is too heavy for the per-commit gate).
// integration_guard_test.go
package ratchet_test
import (
"go/ast"
"go/build/constraint"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
var allowlistedIntegration = map[string]string{
// "test/e2e": "manual suite against a live stack",
}
func TestIntegrationTestsAreExecuted(t *testing.T) {
var orphans []string
err := filepath.Walk(".", func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.IsDir() {
if skipDir(info.Name()) && path != "." {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
if !requiresIntegrationTag(src) {
return nil
}
rel := filepath.ToSlash(path)
f, err := parser.ParseFile(token.NewFileSet(), rel, src, 0)
if err != nil {
return err
}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || !strings.HasPrefix(fn.Name.Name, "Test") || fn.Name.Name == "TestMain" {
continue
}
if strings.Contains(fn.Name.Name, "RealDB") {
continue
}
if allowlisted(rel) {
continue
}
orphans = append(orphans, rel+"::"+fn.Name.Name)
}
return nil
})
if err != nil {
t.Fatal(err)
}
for _, orphan := range orphans {
t.Errorf("integration-tagged test %s runs nowhere automated. Rename it to contain RealDB, drop the build tag, or add an allowlist entry with a reason", orphan)
}
}
func allowlisted(rel string) bool {
for dir := range allowlistedIntegration {
if rel == dir || strings.HasPrefix(rel, dir+"/") {
return true
}
}
return false
}
func requiresIntegrationTag(content []byte) bool {
for _, line := range strings.Split(string(content), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "package ") {
return false
}
if !constraint.IsGoBuild(trimmed) {
continue
}
expr, err := constraint.Parse(trimmed)
if err != nil {
return false
}
with := expr.Eval(func(tag string) bool { return tag == "integration" })
without := expr.Eval(func(string) bool { return false })
return with && !without
}
return false
}
The RealDB convention exists because mocks lie about the schema. pq.Array(nil) becoming NULL against a NOT NULL column is not a rare edge. It is what happens when the test double is more polite than Postgres. make test-realdb is in verify. make migrate-check applies every embedded migration plus the dev seed to a throwaway pgvector container (up, seed, down, up) on a non-default port so it cannot touch the dev database. sqlmock will not tell you a function is not IMMUTABLE enough for an index expression. The live engine will.
Name the write-path round-trips *RealDB* so the gate and the Makefile stay the same document.
§Pin Drift
The most expensive failures this year were not missed bugs in application code. They were moments when make verify and CI disagreed, and we believed the local one.
A documentation claim about a build gate is a claim like any other. It has to be checked by a test, not by review. These two tests fail when CONTRIBUTING.md names a tool version the Makefile does not pin, when CI pins a different version than the Makefile, or when the coverage floor is 82 in one file and 80 in another.
If a file is missing, that row is skipped so the test can land on a tree that does not have codecov.yml yet. If the file exists and the regexp matches nothing, firstSubmatch calls t.Fatalf. A rewrite that deletes the sentence is how 82 in the Makefile becomes 80 in codecov.yml with both tests still green.
// pins_test.go
package ratchet_test
import (
"os"
"regexp"
"testing"
)
func TestGateFiguresAgree(t *testing.T) {
makefile := mustRead(t, "Makefile")
total := makefileVar(t, makefile, "COVERAGE_MIN")
patch := makefileVar(t, makefile, "PATCH_COVERAGE_MIN")
t.Logf("coverage floors: total=%s%% patch=%s%%", total, patch)
checks := []struct {
path, pattern, want, what string
}{
{"codecov.yml", `(?s)project:.*?target:\s*([0-9]+)%`, total, "codecov.yml project target"},
{"codecov.yml", `(?s)patch:.*?target:\s*([0-9]+)%`, patch, "codecov.yml patch target"},
{".github/workflows/ci.yml", `COVERAGE < ([0-9]+)`, total, "ci.yml coverage threshold"},
{"CONTRIBUTING.md", `Total coverage must be at least ([0-9]+)%`, total, "CONTRIBUTING.md total coverage"},
{"CONTRIBUTING.md", `lines your change touches must be at least ([0-9]+)%`, patch, "CONTRIBUTING.md patch coverage"},
}
for _, c := range checks {
b, err := os.ReadFile(c.path)
if err != nil {
t.Logf("skip %s: %v", c.what, err)
continue
}
got := firstSubmatch(t, string(b), c.pattern, c.what)
if got != c.want {
t.Errorf("%s is %s%%, Makefile says %s%%", c.what, got, c.want)
}
}
}
func TestToolPinsAgree(t *testing.T) {
makefile := mustRead(t, "Makefile")
golangci := makefileVar(t, makefile, "GOLANGCI_LINT_VERSION")
gosec := makefileVar(t, makefile, "GOSEC_VERSION")
checks := []struct {
path, pattern, want, what string
}{
{"CONTRIBUTING.md", `golangci-lint/v2/cmd/golangci-lint@(v[0-9.]+)`, golangci, "CONTRIBUTING.md golangci-lint"},
{"CONTRIBUTING.md", `gosec/v2/cmd/gosec@(v[0-9.]+)`, gosec, "CONTRIBUTING.md gosec"},
{".github/workflows/ci.yml", `gosec/v2/cmd/gosec@(v[0-9.]+)`, gosec, "ci.yml gosec"},
{".github/workflows/ci.yml", `(?s)golangci-lint-action@.{0,200}?version:\s*(v[0-9.]+)`, golangci, "ci.yml golangci-lint-action"},
}
for _, c := range checks {
b, err := os.ReadFile(c.path)
if err != nil {
t.Logf("skip %s: %v", c.what, err)
continue
}
matches := allSubmatches(string(b), c.pattern)
if len(matches) == 0 {
t.Errorf("%s: no version found (pattern %s)", c.what, c.pattern)
continue
}
for _, got := range matches {
if got != c.want {
t.Errorf("%s pins %s, Makefile pins %s", c.what, got, c.want)
}
}
}
}
func makefileVar(t *testing.T, makefile, name string) string {
t.Helper()
re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(name) + `\s*:=\s*(\S+)\s*$`)
m := re.FindStringSubmatch(makefile)
if m == nil {
t.Fatalf("Makefile has no %s assignment", name)
}
return m[1]
}
func firstSubmatch(t *testing.T, text, pattern, what string) string {
t.Helper()
m := regexp.MustCompile(pattern).FindStringSubmatch(text)
if m == nil {
t.Fatalf("could not find %s (pattern %s)", what, pattern)
}
return m[1]
}
func allSubmatches(text, pattern string) []string {
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(text, -1)
out := make([]string, 0, len(matches))
for _, m := range matches {
out = append(out, m[1])
}
return out
}
Add a row per file that restates a number. CONTRIBUTING.md, codecov.yml, .github/workflows/ci.yml. A contributor who copies the install line from CONTRIBUTING.md then gets the same golangci-lint version tools-check accepts. The production file is pins_test.go.
tools-check is the other half of this, and it is a Makefile target, not a test. It compares go version -m $(which gosec) (and golangci-lint, and gremlins) to GOSEC_VERSION in the Makefile, and the Makefile is what .github/workflows/ci.yml installs. Verify refuses to start on a mismatch. Override with TOOLS_CHECK_STRICT=0 only in the PR that updates the Makefile pin. A newer local gosec can drop a rule CI still runs. That is how the SSRF in PR #377 reached review.
§Gates That Protect the Gates
Patch-scoped lint that sees the working tree. CI’s golangci-lint-action uses only-new-issues: true. golangci-lint --new-from-rev only sees committed changes, so make lint before the first commit is a no-op and bad code walks through the gate. We generate a unified diff from the merge-base that includes staged and unstaged changes and pass it as --new-from-patch. We also golangci-lint cache clean first, because a warm cache can serve a previously filtered result and report zero issues on a line CI (cold) rejects. That one cost a red Lint job after a green local verify (PR #1303). Silent skip when origin/main is unreachable is how the same class of hole opened in PR #393. The target hard-fails if it cannot see a base.
Docs and maps. CLAUDE.md is the first file a new session loads. A project structure section that omits a pkg/ directory sends the agent to the wrong path. TestClaudeMdCoversPkgDirectories requires every top-level pkg/ name to appear as a word-bounded name/ in that file. Orphaned MkDocs pages, citations that point at headings that were renamed, research pages without a working-paper banner: each of those shipped once, and each is now a test in this repo. I am not pasting them. They match on CLAUDE.md headings and MkDocs paths that only this repo has. If two files in your tree must stay in sync, write the test that reads both.
§What Does Not Belong in Per-Commit Verify
make verify on this repo is already long. It runs tools-check, gofmt, swagger drift, embed-dir cleanliness, unit tests, the real-Postgres migration gate, RealDB, frontend test/lint/e2e, patch-scoped lint, security, Semgrep, CodeQL, coverage, patch coverage, doc-check, dead-code, and a GoReleaser dry-run. Mutation testing is not on that list. A comment in the Makefile forbids putting it back.
Gremlins is the right tool. It still takes too long to run on every revision. It lives in make verify-release and a weekly workflow. Same for load tests and the agent-effectiveness benchmark: they stand up Docker and a real binary and run for tens of seconds to an hour. Putting them in verify trains people (and agents) to skip verify.
Dead-code is informational here, not blocking. Public API false positives are real, and a blocking deadcode on a library module becomes an allowlist nobody trusts. The vaporware tests carry the blocking half: unimported packages, noop-only interfaces, tables with no DML.
The pre-commit review gate is a different layer. Verify writes a 16-character hash of the working-tree diff to .claude/.last-verify-passed. The hook that blocks git commit checks that hash. If they match, this verify run is proof the CI-equivalent suite passed on the exact bytes being committed. If you edit after verify, the hash changes and the hook denies the commit until you run verify again.
§Dropping This on an Existing Project
Do not wait until you have split the god packages and deleted the dead ones. Seed the gates on the tree you have today so the next PR cannot make the numbers worse.
- Copy the
*_test.golistings in this article into the directory that containsgo.mod.package ratchet_testwill not collide withpackage main. - Run
go test -count=1 -v -run TestPackageSizeBudget .andgo test -count=1 -v -run TestPackageExportedSurfaceBudget .. SetmaxPackageLOC,maxPackageFiles, andmaxExportedSurfaceto the printed maxima, or one above. - Add a row to
godObjectsfor the type that already has too many fields. SetmaxFieldsandmaxMethsto the counts-vprints. - Copy the packages your README tells consumers to import into
supportedSurface. Rungo test -count=1 -run TestPublicSurfacePolicy .. Move a failing package underinternal/, or add astabilityAllowlistentry with awhyand anexit. go test -run TestPackageImportRatchet . -args -update-importsand committestdata/allowed_internal_imports.txt.- Run
go test -count=1 -run 'TestNoDeadPackages|TestNoopOnlyInterfaces|TestIntegrationTestsAreExecuted' .. Import or delete an unimported package. Rename an orphaned integration test to containRealDB, or add an allowlist entry with a reason. Do not skip. - Add a
TestGateFiguresAgreerow for every file that restatesCOVERAGE_MIN. Same forTestToolPinsAgreeand every file that names a tool version. - Add
go test -count=1 .to theverifytarget in the Makefile. These tests parse the tree. They do not need a database.
On a new project, set maxPackageLOC to a few hundred and maxExportedSurface to a handful so the first god-package never forms. The February GNUmakefile still handles lint, race, coverage, security, and dead functions. go-leash will scaffold that layer. Add the files in this article when a package is already large enough that a session cannot read all of it.
Append this to CLAUDE.md. Short on purpose. Every line competes for context.
## Structural gates (do not raise a ceiling to land a change)
`go test -count=1 .` at the module root enforces:
- package LOC / file-count budget (`TestPackageSizeBudget`)
- named god-object field and method ceilings (`TestGodObjectBudget`)
- exported-surface budget under pkg/ (`TestPackageExportedSurfaceBudget`)
- supported-surface / single-importer policy (`TestPublicSurfacePolicy`)
- first-party import ratchet (`TestPackageImportRatchet`; regenerate only with a PR justification)
- no unimported pkg/ packages, no noop-only interfaces (`TestNoDeadPackages`, `TestNoopOnlyInterfaces`)
- integration-tagged tests are named *RealDB* or allowlisted (`TestIntegrationTestsAreExecuted`)
- coverage floors and tool pins agree across Makefile / CI / docs (`TestGateFiguresAgree`, `TestToolPinsAgree`)
If a structural test fails: decompose, unexport, move the package under internal/, wire it in, or rename the test. Do not edit the constant. Do not add `//nolint`. Do not grow an allowlist without an exit condition.
§Why This Holds Over Time
The February leash assumed the thing that rots is the function. For a weekend project that is right. For a repo an agent lives in for six months, the failures are: this type has 46 fields, this package now imports that one, this table has no INSERT, this test is named TestPromptCreateRoundTrip and CI never runs it, COVERAGE_MIN is 82 in the Makefile and 80 in codecov.yml.
Humans accumulate those slowly enough that a good reviewer notices. Agents accumulate them at the speed of make verify going green. The only response that has worked for me is to encode those facts as tests, seed the constants on the tree I actually have, and refuse to raise a ceiling to land a change.
I still do not trust make verify. I trust it more than I trust a session that just said “all tests pass” after running three of them. The human review is still the firewall, and it is a lot more useful when the firewall is not also doing the job of a linter that could have been a test.
The production gates, with the longer comments and the exemptions that have names, live in the root of txn2/mcp-data-platform. The constants and allowlists change as extractions land. The files in this article are the ones I would copy into the directory that contains go.mod on day one of letting an agent touch the next Go repo.
AI on a Leash: Go’s constraints · Ralph’s Uncle · Complete Go configuration · Pre-commit review gate · this article