dimetron / code-guidelines-go
Go 1.24–1.27 coding guidelines for the dimetron/pi-go AI agent runtime. Use this skill whenever writing, reviewing, or refactoring ANY Go code in pi-go. This covers idiomatic style, error handling, concurrency, project layout, testing (table-driven, fuzz, benchmarks, synctest), new stdlib usage, golangci-lint v2 configuration, slog logging, and manual dependency injection with functional options. Always consult this skill before generating Go code for pi-go, even for small snippets, to ensure every output is idiomatic and modern.
Go 1.24–1.27 Coding Guidelines — pi-go
Project: github.com/dimetron/pi-go — Go-native AI coding agent runtime
Layout: cmd/pi/ · internal/ · .pi-go/ · .vibe/compiled/ · scripts/
Min version: Go 1.24 (tool directive support) | Target: Go 1.27
For what Go 1.27 changed — generic methods, struct-literal field selectors,
encoding/json/v2, the stdlibuuidpackage, removals that break a build, and theGOEXPERIMENT=nonetrap on this machine — see thego-127skill.
1. Style & Naming
Packages
- Short, lowercase, singular, no underscores:
config✓agentUtils✗ - No stutter: package
agent→type Runner(notAgentRunner) - Never
util,common,types,helpers
Identifiers
- Receivers: 1–2 chars from type name, consistent across all methods, never
self/this - Acronyms full caps:
ID,URL,HTTP,LLM— enforced by staticcheck - Short names in small scopes (
i,ctx,err), descriptive in large scopes
// ✓ Correct
func (r *Runner) Execute(ctx context.Context) error { ... }
func (r *Runner) Stop() { ... }
// ✗ Wrong
func (self *Runner) Execute(ctx context.Context) error { ... }
func (runner *Runner) Stop() { ... }Interfaces
- Define at the consumer, not producer
- 1–3 methods; single-method uses
-ersuffix (Reader,Handler,Closer) - Accept interfaces, return concrete structs
// consumer defines what it needs (internal/agent/)
type LLMClient interface {
Complete(ctx context.Context, prompt string) (string, error)
}
// producer returns concrete struct (internal/llm/)
func NewOpenAIClient(apiKey string) *OpenAIClient { ... }Godoc
Every exported name starts its comment with the name itself:
// Package agent provides the core AI agent runtime for pi-go.
package agent
// Runner orchestrates tool calls and LLM interactions.
type Runner struct { ... }
// Execute runs the agent loop until completion or context cancellation.
func (r *Runner) Execute(ctx context.Context, task string) error { ... }2. Idiomatic & Functional Style
Guard clauses — early return, flat code
Avoid deep nesting. Return errors early, keep the happy path at the left edge:
// ✓ Guard clauses
func (r *Runner) Start(ctx context.Context) error {
if r.running {
return ErrAlreadyRunning
}
if r.client == nil {
return errors.New("nil client")
}
return r.loop(ctx)
}
// ✗ Nested conditionals
func (r *Runner) Start(ctx context.Context) error {
if !r.running {
if r.client != nil {
return r.loop(ctx)
}
return errors.New("nil client")
}
return ErrAlreadyRunning
}Pure functions — isolate logic from I/O
Extract testable logic into pure functions that take inputs and return outputs.
Keep side effects (network, disk, logging) at the edges:
// ✓ Pure — easy to test, no mocks needed
func buildPrompt(system string, history []Message, tools []Tool) string { ... }
func mergeToolResults(existing, incoming []ToolResult) []ToolResult { ... }
func selectModel(budget Budget, task Task) string { ... }
// Wire I/O at the caller
func (r *Runner) Execute(ctx context.Context, task string) error {
prompt := buildPrompt(r.system, r.history, r.tools) // pure
resp, err := r.client.Complete(ctx, prompt) // side effect
...
}Avoid init() — explicit initialization
init() hides execution order and makes testing harder. Wire everything in main or constructors:
// ✗ Hidden global state
func init() { registry.Register("search", searchTool) }
// ✓ Explicit wiring
func NewRegistry(tools ...Tool) *Registry { ... }Value semantics — return values, don't mutate pointers
Prefer returning new values over mutating inputs. Makes data flow visible:
// ✓ Value in, value out
func withDefaults(cfg Config) Config {
if cfg.Timeout == 0 { cfg.Timeout = 30 * time.Second }
if cfg.Model == "" { cfg.Model = "claude-sonnet-4-5-20250514" }
return cfg
}
// ✗ Mutate in place — caller can't see what changed
func applyDefaults(cfg *Config) { ... }Function types as first-class values
Use named function types to simplify callback and middleware patterns:
// Named function type — implements Handler implicitly
type ToolFunc func(ctx context.Context, input json.RawMessage) (string, error)
// Higher-order function — returns a decorated version
func WithTimeout(d time.Duration, fn ToolFunc) ToolFunc {
return func(ctx context.Context, input json.RawMessage) (string, error) {
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
return fn(ctx, input)
}
}Composition over flags — small composable pieces
Prefer combining simple functions over adding boolean parameters:
// ✗ Boolean flags multiply code paths
func Send(msg Message, retry bool, validate bool) error { ... }
// ✓ Compose behaviors
func Send(msg Message) error { ... }
func WithRetry(n int, fn func(Message) error) func(Message) error { ... }
func WithValidation(fn func(Message) error) func(Message) error { ... }
// Usage: send := WithRetry(3, WithValidation(Send))No naked returns — always name what you return
// ✗ Naked return — unclear what's being returned
func parse(s string) (result int, err error) {
result, err = strconv.Atoi(s)
return
}
// ✓ Explicit return values
func parse(s string) (int, error) {
return strconv.Atoi(s)
}3. Error Handling
Rule: Add context at every boundary. Return OR log an error — never both.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
return &cfg, nil
}errors.Is / errors.As / errors.AsType (Go 1.26)
var ErrNotFound = errors.New("not found") // sentinel — package-level var
// errors.Is — value matching through chain
if errors.Is(err, ErrNotFound) { ... }
// errors.AsType — generic, no pre-declaration needed (Go 1.26)
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
slog.Error("path error", "path", pathErr.Path)
}Concurrent errors
Use errgroup for first-error semantics; use errors.Join to collect all:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)
for _, r := range records {
g.Go(func() error { return process(ctx, r) })
}
return g.Wait()4. Concurrency
context.Context — always first parameter, never stored in struct
// ✓ Correct
func (s *Service) Process(ctx context.Context, req Request) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.client.Call(ctx, req)
}Top-level context (main.go)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("fatal", "error", err)
os.Exit(1)
}WaitGroup.Go replaces Add/Done (Go 1.25)
var wg sync.WaitGroup
for _, item := range items {
wg.Go(func() { processItem(item) }) // loop var capture safe since 1.22
}
wg.Wait()sync.OnceValue for lazy singletons (Go 1.21+)
var getConfig = sync.OnceValue(func() *Config {
cfg, err := loadConfig()
if err != nil { panic(fmt.Sprintf("load config: %v", err)) }
return cfg
})
// Usage: cfg := getConfig()Goroutine leak prevention
- Every goroutine must have an exit path (context cancel, channel close, done signal)
- In tests, use
goleak.VerifyTestMain(m)inTestMain goroutineleakpprof profile — GA in Go 1.26 → 1.27, no GOEXPERIMENT needed.
Scrape/debug/pprof/goroutineleak, orpprof.Lookup("goroutineleak").
It misses leaks whose primitive is reachable via a global or a runnable goroutine's
locals, so keepgoleakin tests. Seego-127§3.2
5. Project Layout
pi-go/
├── cmd/pi/main.go # Thin wiring only — no business logic
├── internal/
│ ├── agent/ # Core runner, tool dispatch
│ ├── config/ # Config loading
│ ├── llm/ # LLM client implementations
│ └── tools/ # Tool registry
├── .pi-go/ # Templates & runtime config
├── .vibe/compiled/ # Compiled vibe assets
├── scripts/ # Build / dev scripts
├── go.mod
├── go.sum
├── Makefile
└── .golangci.yml- No
pkg/directory — this is a CLI app, not a library main.goonly: parse config, wire deps, callrun(ctx)
Tool directives (Go 1.24) — replaces tools.go
// go.mod
tool (
github.com/golangci/golangci-lint/cmd/golangci-lint
)go tool golangci-lint run # run tracked tool
go get -u tool # upgrade all toolsEmbedding dot-prefixed directories
//go:embed all:.pi-go // "all:" required — dot-files excluded otherwise
var PiGoConfig embed.FS
//go:embed all:.vibe/compiled
var VibeCompiled embed.FSMakefile targets
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/pi ./cmd/pi/
test:
go test -v -race -buildvcs ./...
lint:
go tool golangci-lint run
audit: test lint
go mod tidy -diff && go mod verify6. Testing
Table-driven tests with t.Run
func TestParseDuration(t *testing.T) {
tests := []struct {
name string
input string
want time.Duration
wantErr bool
}{
{"valid seconds", "30s", 30 * time.Second, false},
{"empty string", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // always parallelise subtests
got, err := ParseDuration(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}- Use
t.Helper()as first line of every test helper t.Context()(Go 1.24) — auto-cancelled context, no manual setup needed- Use
google/go-cmpfor struct comparison overtestify/assert - Package convention:
package foo_testfor API tests,package foofor white-box
Fuzz testing (input parsing must be fuzzed)
func FuzzParseCommand(f *testing.F) {
f.Add("run task --model gpt-4")
f.Add("")
f.Fuzz(func(t *testing.T, input string) {
cmd, err := ParseCommand(input)
if err != nil { t.Skip() }
if got := cmd.String(); got != input {
if _, err2 := ParseCommand(got); err2 != nil {
t.Errorf("round-trip failed: %v", err2)
}
}
})
}
// Run: go test -fuzz=FuzzParseCommand -fuzztime=30s
// Failures auto-saved to testdata/fuzz/ as regression testsb.Loop() for benchmarks (Go 1.24) — replaces b.N
func BenchmarkProcess(b *testing.B) {
data := prepareData() // setup excluded from timing automatically
b.ReportAllocs()
for b.Loop() { // no b.ResetTimer(), no sink var needed
process(data)
}
}
// Run: go test -bench=. -benchmem
// Compare: benchstat old.txt new.txttesting/synctest — deterministic concurrency tests (Go 1.24→1.25 stable)
Use synctest.Test for ANY test involving timers, tickers, timeouts, or context cancellation. Eliminates time.Sleep flakiness entirely:
func TestAgentTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
synctest.Sleep(5*time.Second - time.Nanosecond) // sleep + Wait (Go 1.27)
if ctx.Err() != nil {
t.Fatal("should not have timed out yet")
}
synctest.Sleep(time.Nanosecond)
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatal("should have timed out")
}
})
}Note:
synctest.Runwas deprecated — usesynctest.Test(Go 1.25+).synctest.Sleep(d)(Go 1.27) replaces thetime.Sleep(d); synctest.Wait()pair —
prefer it inside a bubble, since a baretime.Sleepleaves the wake order between the
test and the code under test unpredictable when both sleep the same duration.httptest.NewTestServer(t, h)(Go 1.27) gives an in-memory HTTP server that runs in
synthetic time inside a bubble and self-cleans — nodefer srv.Close().t.Attr(key, value)(Go 1.25) emits structured metadata in test output.
7. Modern Stdlib — Use These Instead of External Libs
| Task | Old / External | ✓ Use Instead |
|---|---|---|
| Random numbers | math/rand | math/rand/v2 |
| Slice sort/search | hand-written | slices.Sort, slices.Contains |
| Map utilities | hand-written | maps.Keys, maps.Clone |
| Default value | ternary logic | cmp.Or(a, b, "default") |
| Custom iteration | returning slices | iter.Seq[V], iter.Seq2[K,V] |
| Lazy singleton | sync.Once + var | sync.OnceValue |
| Directory FS safety | path.Join | os.Root (prevents traversal) |
| Zero-value JSON omit | omitempty | omitzero tag |
| Split on last separator | strings.LastIndex + slicing | strings.CutLast / bytes.CutLast (1.27) |
| UUIDs | github.com/google/uuid | stdlib uuid (1.27); uuid.NewV7() for keys |
// iter.Seq2 for lazy DB rows
func (db *DB) Rows(ctx context.Context) iter.Seq2[Row, error] {
return func(yield func(Row, error) bool) {
rows, _ := db.Query(ctx)
defer rows.Close()
for rows.Next() {
var r Row; rows.Scan(&r)
if !yield(r, nil) { return }
}
}
}
// slices + cmp replacing manual loops
slices.SortFunc(users, func(a, b User) int { return cmp.Compare(a.Name, b.Name) })
sortedKeys := slices.Sorted(maps.Keys(m))
port := cmp.Or(envPort, flagPort, "8080")Go 1.24 new packages (use where relevant)
weak.Make(obj)— weak pointer for memory-efficient cachesunique.Make(v)— value interning for fast equalitycrypto/mlkem,crypto/hkdf,crypto/pbkdf2,crypto/sha3— prefer over externalos.Root— scoped filesystem access, prevents path traversal
Go 1.26 syntax
// new(expr) — eliminates ptr() helper boilerplate
timeout := new(30 * time.Second) // *time.Duration pointing to 30s
enabled := new(true) // *bool
// errors.AsType — already shown in §2Go 1.27 syntax
// Generic methods — a generic operation can now live on the type it belongs to.
// Restriction: interfaces cannot declare them, and they cannot satisfy an interface
// method — so keep the package-level generic func wherever the call site is an
// interface (which, per §1, is most of pi-go).
func (b Box[T]) Map[U any](f func(T) U) Box[U] { return Box[U]{v: f(b.v)} }
// Struct literal keys may be any valid field selector — promoted fields included
u := User{ID: 7, Name: "Mittens"} // was User{Base: Base{ID: 7}, Name: ...}
// Function type inference now applies in conversions and composite literals too
ops := []func([]int) int{first, last} // was {first[int], last[int]}Full detail, including removals that break a build: go-127 skill.
8. golangci-lint v2 — Mandatory Gate
All Go code MUST pass golangci-lint run before commit. Run it after every change:
golangci-lint run ./... # check all packages
golangci-lint run ./internal/... # check specific subtreeIf a linter fires, fix the code — do not add //nolint without a comment explaining why the suppression is necessary.
Active linters (from .golangci.yml)
| Category | Linters |
|---|---|
| Correctness | errcheck, govet, staticcheck, unused, ineffassign |
| Style & bugs | bodyclose, copyloopvar, durationcheck, errname, errorlint, fatcontext, misspell, nilerr, revive, unconvert, wastedassign |
| Formatters | gofmt, goimports (local prefix: github.com/dimetron/pi-go) |
Key settings to be aware of
- errcheck:
check-type-assertions: true— always handle type assertion ok values - govet: all analyzers enabled except
fieldalignmentandshadow - revive: enforces
indent-error-flow(guard clauses),receiver-naming,error-strings,superfluous-else,empty-block - staticcheck: all checks enabled (ST1000 package comments excluded for internal)
- misspell: US locale
Exclusions
- Test files (
_test.go):errcheck,bodyclose,nilerrrelaxed internal/tools/:nilerrrelaxed (errors returned inside result structs)internal/(lsp|cli|memory|tui)/:nilerrrelaxed (callback wrappers)research/: most linters disabled (experimental code)
Import ordering (enforced by goimports)
import (
// 1. stdlib
"context"
"fmt"
// 2. third-party
"github.com/charmbracelet/bubbletea"
// 3. local (auto-grouped by goimports local-prefixes)
"github.com/dimetron/pi-go/internal/config"
)CI (GitHub Actions)
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with: { go-version: '1.27' }
- uses: golangci/golangci-lint-action@v9
with: { version: v2 }
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with: { go-version: '1.27' }
- run: go test -v -race -coverprofile=coverage.out ./...
- run: go mod tidy -diff && go mod verify9. Logging — log/slog (stdlib only)
Decision: log/slog for pi-go. Zero deps, swappable handler, context-aware.
If throughput ever matters: swap backend to zapslog.NewHandler() — no call-site changes.
// internal/logging/logging.go
func NewLogger(level slog.Level, jsonOutput bool) *slog.Logger {
opts := &slog.HandlerOptions{Level: level, AddSource: level <= slog.LevelDebug}
var h slog.Handler
if jsonOutput {
h = slog.NewJSONHandler(os.Stderr, opts) // production
} else {
h = slog.NewTextHandler(os.Stderr, opts) // development
}
return slog.New(h)
}- Never use
log(stdlib old),fmt.Println, or init a globalzap.Logger—depguardwill catch it - Pass logger via constructor, not context (context logging = middleware only)
- Use
.With()for component-scoped fields - Go 1.26:
slog.NewMultiHandler(h1, h2)for fan-out
type Runner struct { logger *slog.Logger }
func NewRunner(client LLMClient, logger *slog.Logger) *Runner {
return &Runner{logger: logger.With("component", "runner")}
}
// In methods
r.logger.Info("executing task", slog.String("task", task), slog.Int("iter", n))
r.logger.Error("tool failed", slog.Any("error", err), slog.String("tool", name))10. Dependency Injection — Manual + Functional Options
No Wire, no fx. Manual constructor injection wired in cmd/pi/main.go.
Functional options for configurable constructors
type clientConfig struct {
model string
timeout time.Duration
}
type Option func(*clientConfig)
func WithModel(m string) Option { return func(c *clientConfig) { c.model = m } }
func WithTimeout(d time.Duration) Option { return func(c *clientConfig) { c.timeout = d } }
func NewOpenAIClient(apiKey string, opts ...Option) *OpenAIClient {
cfg := &clientConfig{model: "gpt-4o", timeout: 30 * time.Second}
for _, o := range opts { o(cfg) }
return &OpenAIClient{apiKey: apiKey, cfg: cfg}
}Composition root (cmd/pi/main.go)
func run(ctx context.Context) error {
cfg := config.Load()
logger := logging.NewLogger(cfg.LogLevel, cfg.JSONOutput)
slog.SetDefault(logger)
llmClient := llm.NewOpenAIClient(cfg.APIKey,
llm.WithModel(cfg.Model),
llm.WithTimeout(cfg.Timeout),
)
registry := tools.NewRegistry(logger, tools.WithBuiltins())
runner := agent.NewRunner(llmClient, registry, logger,
agent.WithMaxIterations(cfg.MaxIterations),
)
return runner.Run(ctx, os.Args[1:])
}Interface-based test mocks — no mock framework needed
type mockLLM struct{ response string; err error }
func (m *mockLLM) Complete(_ context.Context, _ string) (string, error) {
return m.response, m.err
}
func TestRunner_Execute(t *testing.T) {
r := agent.NewRunner(&mockLLM{response: "ok"}, nil, slog.Default())
if err := r.Execute(t.Context(), "task"); err != nil {
t.Fatalf("unexpected: %v", err)
}
}Quick Reference
| Concern | Use | Avoid |
|---|---|---|
| Control flow | Guard clauses, early return | Deep nesting |
| Logic | Pure functions, value in/out | Mutating pointer args |
| Composition | Small functions + higher-order combinators | Boolean flag parameters |
| Init | Explicit constructors | init() functions |
| Returns | Explicit return values | Naked returns |
| Lint gate | golangci-lint run before commit | //nolint without justification |
| Imports | stdlib / third-party / local (3 groups) | Mixed or unsorted imports |
| Logging | log/slog | log, zap, zerolog |
| DI | manual constructors + functional options | Wire, fx |
| Error wrapping | fmt.Errorf("...: %w", err) | %v when caller needs to unwrap |
| Error type check | errors.AsType[T] (1.26) / errors.As | type assertions |
| Benchmarks | b.Loop() | for i := 0; i < b.N; i++ |
| Concurrent tests | synctest.Test + synctest.Sleep (1.27) | time.Sleep in tests |
| HTTP test server | httptest.NewTestServer(t, h) (1.27) | httptest.NewServer + defer Close() |
| Split on last separator | strings.CutLast (1.27) | strings.LastIndex + slicing |
| UUIDs | stdlib uuid (1.27) | github.com/google/uuid |
| WaitGroup | wg.Go(func(){...}) (1.25) | wg.Add(1); defer wg.Done() |
| Slice ops | slices.*, maps.* | hand-written loops |
| Randomness | math/rand/v2 | math/rand |
| Tool deps | go.mod tool directive (1.24) | tools.go blank imports |