LLMSkillHub
mirroredCopied from dimetron/pi-go under MIT. dimetron has not claimed this page, and is not affiliated with LLM SkillHub.

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.

0
SKILL.md
22.7 KB

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 stdlib uuid package, removals that break a build, and the
GOEXPERIMENT=none trap on this machine — see the go-127 skill.


1. Style & Naming

Packages

  • Short, lowercase, singular, no underscores: config ✓ agentUtils ✗
  • No stutter: package agent → type Runner (not AgentRunner)
  • 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 -er suffix (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) in TestMain
  • goroutineleak pprof profile — GA in Go 1.26 → 1.27, no GOEXPERIMENT needed.
    Scrape /debug/pprof/goroutineleak, or pprof.Lookup("goroutineleak").
    It misses leaks whose primitive is reachable via a global or a runnable goroutine's
    locals, so keep goleak in tests. See go-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.go only: parse config, wire deps, call run(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 tools

Embedding 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.FS

Makefile 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 verify

6. 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-cmp for struct comparison over testify/assert
  • Package convention: package foo_test for API tests, package foo for 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 tests

b.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.txt

testing/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.Run was deprecated — use synctest.Test (Go 1.25+).
synctest.Sleep(d) (Go 1.27) replaces the time.Sleep(d); synctest.Wait() pair —
prefer it inside a bubble, since a bare time.Sleep leaves 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 — no defer srv.Close().
t.Attr(key, value) (Go 1.25) emits structured metadata in test output.


7. Modern Stdlib — Use These Instead of External Libs

TaskOld / External✓ Use Instead
Random numbersmath/randmath/rand/v2
Slice sort/searchhand-writtenslices.Sort, slices.Contains
Map utilitieshand-writtenmaps.Keys, maps.Clone
Default valueternary logiccmp.Or(a, b, "default")
Custom iterationreturning slicesiter.Seq[V], iter.Seq2[K,V]
Lazy singletonsync.Once + varsync.OnceValue
Directory FS safetypath.Joinos.Root (prevents traversal)
Zero-value JSON omitomitemptyomitzero tag
Split on last separatorstrings.LastIndex + slicingstrings.CutLast / bytes.CutLast (1.27)
UUIDsgithub.com/google/uuidstdlib 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 caches
  • unique.Make(v) — value interning for fast equality
  • crypto/mlkem, crypto/hkdf, crypto/pbkdf2, crypto/sha3 — prefer over external
  • os.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 §2

Go 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 subtree

If a linter fires, fix the code — do not add //nolint without a comment explaining why the suppression is necessary.

Active linters (from .golangci.yml)

CategoryLinters
Correctnesserrcheck, govet, staticcheck, unused, ineffassign
Style & bugsbodyclose, copyloopvar, durationcheck, errname, errorlint, fatcontext, misspell, nilerr, revive, unconvert, wastedassign
Formattersgofmt, 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 fieldalignment and shadow
  • 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, nilerr relaxed
  • internal/tools/: nilerr relaxed (errors returned inside result structs)
  • internal/(lsp|cli|memory|tui)/: nilerr relaxed (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 verify

9. 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 global zap.Logger — depguard will 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

ConcernUseAvoid
Control flowGuard clauses, early returnDeep nesting
LogicPure functions, value in/outMutating pointer args
CompositionSmall functions + higher-order combinatorsBoolean flag parameters
InitExplicit constructorsinit() functions
ReturnsExplicit return valuesNaked returns
Lint gategolangci-lint run before commit//nolint without justification
Importsstdlib / third-party / local (3 groups)Mixed or unsorted imports
Logginglog/sloglog, zap, zerolog
DImanual constructors + functional optionsWire, fx
Error wrappingfmt.Errorf("...: %w", err)%v when caller needs to unwrap
Error type checkerrors.AsType[T] (1.26) / errors.Astype assertions
Benchmarksb.Loop()for i := 0; i < b.N; i++
Concurrent testssynctest.Test + synctest.Sleep (1.27)time.Sleep in tests
HTTP test serverhttptest.NewTestServer(t, h) (1.27)httptest.NewServer + defer Close()
Split on last separatorstrings.CutLast (1.27)strings.LastIndex + slicing
UUIDsstdlib uuid (1.27)github.com/google/uuid
WaitGroupwg.Go(func(){...}) (1.25)wg.Add(1); defer wg.Done()
Slice opsslices.*, maps.*hand-written loops
Randomnessmath/rand/v2math/rand
Tool depsgo.mod tool directive (1.24)tools.go blank imports