| name | go-vibe-safe-coding-setup |
| description | Install and configure a "vibe-safe" Go toolchain (golangci-lint v2 with the comprehensive linter set + gofumpt + goimports + arch_test.go AST-based architectural rules + coverage ratchet + ASCII enforcement + Makefile verify pipeline) so AI-generated Go is caught before it ships. TRIGGER when the user is starting a new Go service/CLI/worker, asks to "set up linting", "add golangci-lint", "make this Go project safe for vibe coding", "harden this Go service", or wants to mirror a known-good baseline. SKIP for one-off rule tweaks in an already-configured project. |
Go Vibe-Safe Coding Setup
A reusable, opinionated golangci-lint v2 baseline plus AST-based architectural tests that catch the kinds of mistakes LLMs commonly slip into Go code: unchecked errors, naked returns, magic numbers, swallowed errors, leaked HTTP bodies, blind type assertions, missing context propagation, fmt.Print in production, naive os.Getenv access, panics in production, raw SQL strings, missing JSON tags, global mutable state, commented-out code.
When to apply
- New Go module/service with no
.golangci.yml, or only a default one.
- Existing project where the user explicitly wants stricter guardrails.
Always confirm before overwriting an existing .golangci.yml, Makefile, arch_test.go, or pre-commit hook.
Step 1 - Install tools
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install mvdan.cc/gofumpt@latest
go install golang.org/x/tools/cmd/goimports@latest
Step 2 - .golangci.yml
version: '2'
run:
timeout: 5m
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- misspell
- unconvert
- gocritic
- exhaustive
- nilerr
- bodyclose
- prealloc
- revive
- forbidigo
- funlen
- cyclop
- nestif
- gocognit
- mnd
- wrapcheck
- errorlint
- errname
- err113
- thelper
- nilnil
- forcetypeassert
- predeclared
- usestdlibvars
- perfsprint
- mirror
- gosec
- bidichk
- goconst
- dupl
- dupword
- sloglint
- loggercheck
- sqlclosecheck
- nosprintfhostport
- canonicalheader
- musttag
- nakedret
- godot
- asciicheck
- whitespace
- nolintlint
- modernize
- copyloopvar
- intrange
- iface
- interfacebloat
- wastedassign
- makezero
- reassign
- durationcheck
- godox
settings:
funlen:
lines: 50
statements: 40
ignore-comments: true
cyclop:
max-complexity: 10
nestif:
min-complexity: 4
gocognit:
min-complexity: 15
mnd:
ignored-numbers: ['0', '1', '-1']
ignored-functions:
- 'time.*'
- 'make'
- 'http.Status*'
- 'context.*'
- 'net.Listen'
ignored-files:
- '_test\.go$'
wrapcheck:
ignore-sigs:
- '.Errorf('
- 'errors.New('
- 'apperrors.'
gocritic:
enabled-checks:
- dupImport
- nestingReduce
- truncateCmp
- unnamedResult
- commentedOutCode
- hugeParam
- rangeValCopy
misspell:
locale: US
forbidigo:
forbid:
- pattern: 'fmt\.Print.*'
- pattern: '^log\.(Print|Fatal|Panic)'
- pattern: 'os\.Getenv'
- pattern: 'os\.Setenv'
goconst:
min-len: 3
min-occurrences: 3
ignore-tests: true
dupl:
threshold: 100
nakedret:
max-func-lines: 5
interfacebloat:
max: 10
sloglint:
no-mixed-args: true
kv-only: true
godox:
keywords: [FIXME, BUG, HACK]
nolintlint:
require-explanation: true
require-specific: true
gosec:
exclude-generated: true
exhaustive:
default-signifies-exhaustive: true
revive:
rules:
- name: early-return
- name: blank-imports
- name: context-as-argument
- name: error-return
- name: unexported-return
- name: unused-parameter
- name: var-naming
- name: exported
- name: cognitive-complexity
arguments: [10]
- name: argument-limit
arguments: [4]
- name: max-control-nesting
arguments: [4]
- name: deep-exit
- name: unchecked-type-assertion
exclusions:
rules:
- path: '_test\.go$'
linters:
- funlen
- cyclop
- nestif
- gocognit
- mnd
- wrapcheck
- forbidigo
- errname
- forcetypeassert
- nilnil
- errorlint
- usestdlibvars
- perfsprint
- revive
- prealloc
- gosec
- goconst
- dupl
- nakedret
- godox
- nolintlint
- sloglint
- wastedassign
- err113
- loggercheck
- musttag
- canonicalheader
- path: 'config/config\.go$'
linters:
- forbidigo
formatters:
enable:
- gofumpt
- goimports
issues:
max-issues-per-linter: 0
max-same-issues: 0
Step 3 - config/config.go (the only allowed env reader)
The forbidigo rules ban os.Getenv everywhere except this file:
package config
import (
"errors"
"fmt"
"os"
)
var errInvalidConfig = errors.New("invalid config")
type Config struct {
DatabaseURL string
LogLevel string
}
func Load() (*Config, error) {
c := &Config{
DatabaseURL: os.Getenv("DATABASE_URL"),
LogLevel: getEnvDefault("LOG_LEVEL", "INFO"),
}
if err := c.validate(); err != nil {
return nil, fmt.Errorf("%w: %w", errInvalidConfig, err)
}
return c, nil
}
func getEnvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func (c *Config) validate() error {
allowed := map[string]bool{"DEBUG": true, "INFO": true, "WARNING": true, "ERROR": true}
if !allowed[c.LogLevel] {
return fmt.Errorf("LOG_LEVEL %q not in DEBUG/INFO/WARNING/ERROR", c.LogLevel)
}
return nil
}
Step 4 - apperrors package (typed errors with blame attribution)
Parity with the TS/Python skills. apperrors/apperrors.go:
package apperrors
import "fmt"
type Blame string
const (
BlameClient Blame = "client"
BlameServer Blame = "server"
BlameExternal Blame = "external"
)
type AppError struct {
Message string
StatusCode int
Blame Blame
UserMessage string
}
func (e *AppError) Error() string { return fmt.Sprintf("%s: %s", e.Blame, e.Message) }
func NewClient(msg string) *AppError {
return &AppError{Message: msg, StatusCode: 400, Blame: BlameClient, UserMessage: msg}
}
func NewServer(msg string) *AppError {
return &AppError{Message: msg, StatusCode: 500, Blame: BlameServer, UserMessage: msg}
}
func NewExternal(msg string) *AppError {
return &AppError{Message: msg, StatusCode: 502, Blame: BlameExternal, UserMessage: msg}
}
The wrapcheck config already exempts apperrors. calls from wrap requirements.
Step 5 - arch_test.go (AST-based architectural rules)
golangci-lint can't enforce file size, doc-comment requirements on exported APIs, or "no raw SQL strings" in code. This Go test does, using go/parser and go/ast. Drop at the module root:
package main
import (
"bufio"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
func productionGoFiles(t *testing.T, excludeDirs ...string) []string {
t.Helper()
var files []string
err := filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
for _, ex := range excludeDirs {
if strings.HasPrefix(path, ex) {
return filepath.SkipDir
}
}
return nil
}
if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
return files
}
func scanFileForPattern(t *testing.T, path string, patterns []string) []string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer func() { _ = f.Close() }()
var hits []string
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
for _, p := range patterns {
if strings.Contains(line, p) {
hits = append(hits, path+":"+itoa(lineNum)+": "+strings.TrimSpace(line))
}
}
}
return hits
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func TestNoRawEnvAccess(t *testing.T) {
files := productionGoFiles(t, "config")
var v []string
for _, f := range files {
v = append(v, scanFileForPattern(t, f, []string{"os.Getenv", "os.Setenv"})...)
}
if len(v) > 0 {
t.Errorf("raw env access. Use the config package:\n%s", strings.Join(v, "\n"))
}
}
func TestNoPanicInProduction(t *testing.T) {
files := productionGoFiles(t)
var v []string
for _, f := range files {
v = append(v, scanFileForPattern(t, f, []string{"panic("})...)
}
if len(v) > 0 {
t.Errorf("panic() in production. Return errors:\n%s", strings.Join(v, "\n"))
}
}
func TestFileMaxLines(t *testing.T) {
const maxLines = 300
for _, path := range productionGoFiles(t) {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if n := strings.Count(string(data), "\n"); n > maxLines {
t.Errorf("%s has %d lines (max %d)", path, n, maxLines)
}
}
}
func TestExportedHaveDocComments(t *testing.T) {
fset := token.NewFileSet()
for _, path := range productionGoFiles(t) {
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
for _, decl := range node.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
if d.Name.IsExported() && d.Doc == nil {
p := fset.Position(d.Pos())
t.Errorf("%s:%d: exported func %s missing doc comment", p.Filename, p.Line, d.Name.Name)
}
case *ast.GenDecl:
if d.Tok != token.TYPE {
continue
}
for _, spec := range d.Specs {
ts, ok := spec.(*ast.TypeSpec)
if ok && ts.Name.IsExported() && d.Doc == nil {
p := fset.Position(ts.Pos())
t.Errorf("%s:%d: exported type %s missing doc comment", p.Filename, p.Line, ts.Name.Name)
}
}
}
}
}
}
func TestNoRawSQLStrings(t *testing.T) {
fset := token.NewFileSet()
keywords := []string{"SELECT ", "INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "CREATE TABLE"}
markers := []string{"$1", "?"}
var v []string
for _, path := range productionGoFiles(t) {
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
ast.Inspect(node, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
upper := strings.ToUpper(lit.Value)
for _, kw := range keywords {
if !strings.Contains(upper, kw) {
continue
}
safe := false
for _, m := range markers {
if strings.Contains(lit.Value, m) {
safe = true
break
}
}
if !safe {
p := fset.Position(lit.Pos())
v = append(v, p.Filename+":"+itoa(p.Line)+": "+lit.Value)
}
break
}
return true
})
}
if len(v) > 0 {
t.Errorf("raw SQL. Use parameterized queries:\n%s", strings.Join(v, "\n"))
}
}
func TestAllJSONStructsHaveTags(t *testing.T) {
fset := token.NewFileSet()
var v []string
for _, path := range productionGoFiles(t) {
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
ast.Inspect(node, func(n ast.Node) bool {
st, ok := n.(*ast.StructType)
if !ok || st.Fields == nil {
return true
}
has := false
for _, f := range st.Fields.List {
if f.Tag != nil && strings.Contains(f.Tag.Value, `json:`) {
has = true
break
}
}
if !has {
return true
}
for _, f := range st.Fields.List {
for _, name := range f.Names {
if name.IsExported() && (f.Tag == nil || !strings.Contains(f.Tag.Value, `json:`)) {
p := fset.Position(name.Pos())
v = append(v, p.Filename+":"+itoa(p.Line)+": "+name.Name+" missing json tag")
}
}
}
return true
})
}
if len(v) > 0 {
t.Errorf("missing json tags:\n%s", strings.Join(v, "\n"))
}
}
func TestNoGlobalMutableState(t *testing.T) {
allowList := map[string]map[string]bool{
"main.go": {"sqlDriver": true},
"config.go": {"errInvalidConfig": true},
}
fset := token.NewFileSet()
var v []string
for _, path := range productionGoFiles(t) {
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
base := filepath.Base(path)
for _, decl := range node.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for _, name := range vs.Names {
if a, ok := allowList[base]; ok && a[name.Name] {
continue
}
p := fset.Position(name.Pos())
v = append(v, p.Filename+":"+itoa(p.Line)+": package-level var "+name.Name)
}
}
}
}
if len(v) > 0 {
t.Errorf("global mutable state. Use DI or function-local vars:\n%s", strings.Join(v, "\n"))
}
}
func TestNoCommentedOutCode(t *testing.T) {
patterns := []string{"// func ", "// var ", "// type ", "// if ", "// for ", "// return ", "// switch "}
for _, path := range productionGoFiles(t) {
f, err := os.Open(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
for _, p := range patterns {
if strings.HasPrefix(line, p) {
t.Errorf("%s:%d: commented-out code: %s", path, lineNum, line)
}
}
}
_ = f.Close()
}
}
Adjust the allowList in TestNoGlobalMutableState if your project legitimately needs other package-level vars (e.g. test injection points).
Step 6 - ASCII enforcement
If the parent monorepo already has scripts/check-ascii.sh, just call it from the worker Makefile. Standalone Go projects: drop the same script in (see TS or Python skill for the script body).
Step 7 - Coverage ratchet (scripts/coverage-ratchet.sh)
Coverage that can only go up. Reads .coverage-baseline.json, fails if current is below baseline minus tolerance.
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SERVICE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COVERAGE_FILE="$SERVICE_DIR/coverage.out"
BASELINE_FILE="$SERVICE_DIR/.coverage-baseline.json"
TOLERANCE="0.5"
get_coverage() {
go tool cover -func="$COVERAGE_FILE" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }'
}
if [ ! -f "$COVERAGE_FILE" ]; then
echo "Coverage file not found. Run 'make test' first." >&2
exit 1
fi
CURRENT=$(get_coverage)
if [ "${1:-}" = "--update" ]; then
printf '{"statements": %s}\n' "$CURRENT" > "$BASELINE_FILE"
echo "Baseline updated: ${CURRENT}%"
exit 0
fi
if [ ! -f "$BASELINE_FILE" ]; then
printf '{"statements": %s}\n' "$CURRENT" > "$BASELINE_FILE"
echo "Baseline created at ${CURRENT}%"
exit 0
fi
BASELINE=$(python3 -c "import json; print(json.load(open('$BASELINE_FILE'))['statements'])")
PASSED=$(python3 -c "print('PASS' if $CURRENT - $BASELINE >= -$TOLERANCE else 'FAIL')")
echo "statements: ${BASELINE}% -> ${CURRENT}% $PASSED"
[ "$PASSED" = "PASS" ] || exit 1
chmod +x scripts/coverage-ratchet.sh.
Step 8 - Makefile
.PHONY: lint test build verify coverage-check coverage-ratchet format format-check ascii
GOFUMPT := $(shell go env GOPATH)/bin/gofumpt
lint:
golangci-lint run ./...
format:
$(GOFUMPT) -w .
goimports -w .
format-check:
@test -z "$$($(GOFUMPT) -l .)" || (echo "Files need gofumpt:"; $(GOFUMPT) -l .; exit 1)
test:
go test -v -race -coverprofile=coverage.out ./...
coverage-check:
@go tool cover -func=coverage.out | grep total | awk '{total=$$3} END {if (total+0 < 95) {print "Coverage " total " below 95%"; exit 1}}'
coverage-ratchet:
./scripts/coverage-ratchet.sh
ascii:
bash scripts/check-ascii.sh
build:
go build ./...
verify: format-check lint ascii test coverage-check coverage-ratchet build
The 95% floor is a hard threshold; the ratchet enforces "no regression beyond 0.5pp" on top of that.
Step 9 - Pre-commit / pre-push hook
Standalone projects, install pre-commit and use:
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v2.0.0
hooks:
- id: golangci-lint
In a JS monorepo with Husky, in .lintstagedrc:
{
"services/worker/**/*.go": [
"bash -c 'cd services/worker && golangci-lint run --fix ./... && $(go env GOPATH)/bin/gofumpt -w .'"
]
}
And .husky/pre-push:
cd services/worker && make verify
Step 10 - Verify
Run make verify and resolve every issue. If existing code floods with violations:
- For deliberate violations, use
//nolint:linter // reason (the nolintlint rule requires both the specific linter and a justification).
- Never disable a linter globally to silence noise - fix the code or add a tightly-scoped
exclusions rule.
What NOT to do
- Don't add
//nolint without specifying the linter and reason.
- Don't use
fmt.Println for logging. Use slog (the sloglint rule enforces structured key-value logging).
- Don't
if err != nil { return err } at boundaries - wrap with fmt.Errorf("...: %w", err). The wrapcheck rule enforces this except for the configured signatures.
- Don't read
os.Getenv outside the config package. The arch_test will fail even if forbidigo is bypassed.
- Don't compare errors with string equality. The
errorlint rule will flag this.
- Don't initialize the coverage baseline at 0% - run real tests first, then ratchet up.
- Don't disable
arch_test.go to make it green. The architectural tests catch what golangci-lint can't.