skills/go-coding/SKILL.md
Go coding guidelines for production-quality code — pool lifetimes, concurrency safety, numeric type boundaries, error handling, and test discipline
npx skillsauth add mattdurham/bob bob:internal:go-codingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
These guidelines address patterns that appear in code review again and again. Read this before writing Go code that touches pooled resources, concurrent I/O, or external data sources.
Also injected into fix cycles via .bob/state/fix-prompt.md so workflow-coder follows these rules during every repair iteration.
The rule: Return a pooled object to the pool only when ALL data derived from it has been consumed.
buf := pool.Get().(*[]byte)
results := process(buf) // results contains slices or references into *buf
pool.Put(buf) // RACE: results still alive, any access reads freed memory
return results
buf := pool.Get().(*[]byte)
results := process(buf)
// Document: caller must call pool.Put(buf) after the last use of results.
return results, buf
Ask before every pool.Put / Release call:
Typed-nil guard: Any release function that accepts an interface must guard against typed-nil — a (*T)(nil) passes a type assertion but panics on dereference:
func ReleaseWidget(w Widget) {
if c, ok := w.(*concreteWidget); ok && c != nil {
widgetPool.Put(c)
}
}
Pool + testing.AllocsPerRun: sync.Pool drops entries at GC, which AllocsPerRun triggers. Don't assert exactly 0 allocations on pooled code paths — accept ≤1, or warm the pool inside the measurement closure.
// ❌ Wrong — two concurrent writers for the same destination corrupt each other
tmp := dest + ".tmp"
os.WriteFile(tmp, data, 0o600)
os.Rename(tmp, dest)
// ✅ Right — each writer gets a unique temp name; Rename is atomic
f, err := os.CreateTemp(filepath.Dir(dest), filepath.Base(dest)+".tmp-*")
if err != nil { return err }
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
f.Close()
return os.Rename(tmp, dest)
// ❌ Wrong — new writer can recreate the path between Unlock and Remove
mu.Lock()
delete(index, key)
mu.Unlock()
os.Remove(path) // may delete a newly written file, not the evicted one
// ✅ Safe option A: unique file names per write (from CreateTemp)
// The evicted path can never alias a new write, so removal is safe after unlock.
// ✅ Safe option B: hold the lock during short removals
mu.Lock()
delete(index, key)
os.Remove(path) // deterministic path — hold lock so no new writer can race
mu.Unlock()
// ❌ Wrong — two goroutines both pass the "already cached?" check and both write
if _, ok := index[key]; !ok {
write(key, value) // both goroutines reach here; loser cleanup deletes winner's file
}
// ✅ Right — deduplicate in-flight writes with singleflight
var sf singleflight.Group
sf.Do(key, func() (any, error) {
return nil, writeAndIndex(key, value)
})
// ❌ Wrong — unbounded goroutines; can exhaust fds, memory, or downstream rate limits
for _, item := range items {
go func(item Item) { process(item) }(item)
}
// ✅ Right — bounded worker pool with context cancellation
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // tune to downstream capacity; document the rationale
for _, item := range items {
item := item
g.Go(func() error { return process(ctx, item) })
}
if err := g.Wait(); err != nil { return err }
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
for _, item := range items {
result, err := fetch(ctx, item)
if err != nil { return err }
if done(result) {
cancel() // stop in-flight workers
break
}
}
// ❌ Wrong — fetches everything before processing; defeats early-stop; spikes memory
all := fetchAll(items)
for _, r := range all { process(r) }
// ✅ Right — bounded prefetch pipeline
sem := make(chan struct{}, 4)
for _, item := range items {
if ctx.Err() != nil { break }
sem <- struct{}{}
go func(item Item) {
defer func() { <-sem }()
r := fetch(ctx, item)
process(r) // cancel ctx on limit reached
}(item)
}
Go's make, slice indices, and most standard library functions require int, not int64. Sizes from external sources (disk, network, protobuf) are often int64 or uint64 — always validate and convert explicitly.
// ❌ Won't compile — make requires int
size := computeSize() // returns int64
buf := make([]byte, size)
chunk := buf[offset : offset+length] // offset, length are int64
// ✅ Validate then convert
if size < 0 || size > maxAllowed {
return fmt.Errorf("invalid size %d", size)
}
buf := make([]byte, int(size))
// same for offset and length
Sizes derived from external data (files, headers, wire format):
// Subtraction can produce negative results — always check before using
dataSize := totalSize - int64(headerLen)
if dataSize < 0 {
return fmt.Errorf("corrupt header: negative data size %d", dataSize)
}
if dataSize > maxDataSize {
return fmt.Errorf("data too large: %d bytes", dataSize)
}
Length fields from untrusted sources:
n := binary.LittleEndian.Uint32(buf[0:4])
if n == 0 || n > maxLen {
return fmt.Errorf("invalid length field %d", n)
}
data := make([]byte, int(n))
// ❌ Wrong — treats all failures as a clean miss; hides real I/O errors
data, err := readFromStore(key)
if err != nil {
return nil, false, nil // permission denied? disk full? both silently ignored
}
// ✅ Right — only absence is a miss; everything else propagates
data, err := readFromStore(key)
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("store read %q: %w", key, err)
}
// Bound reads from external sources
r := io.LimitReader(src, maxBytes)
data, err := io.ReadAll(r)
// Validate length fields before allocation
if n > maxAllowed {
return fmt.Errorf("length %d exceeds maximum %d", n, maxAllowed)
}
// ❌ Loses call site information
return err
// ✅ Caller knows where and why
return fmt.Errorf("load config from %s: %w", path, err)
// ❌ Silent failure
_ = file.Close()
// ✅ At minimum log; for write paths, return or join the error
if err := file.Close(); err != nil {
return fmt.Errorf("close %s: %w", file.Name(), err)
}
// ❌ Misleading — name promises zero allocs; body allows several
func TestProcessZeroAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(100, fn)
assert.Less(t, allocs, 5.0)
}
// ✅ Name reflects the actual regression guard
func TestProcessNoMapAllocPerCall(t *testing.T) {
allocs := testing.AllocsPerRun(100, fn)
assert.Less(t, allocs, 2.0)
}
//go:noinline
func storeValue(c *Cache[Thing]) {
v := &Thing{id: 1}
c.Put("key", v)
// v goes out of scope when storeValue returns
}
func TestEvictedAfterGC(t *testing.T) {
c := NewCache[Thing]()
storeValue(c) // strong ref dropped on return
runtime.GC()
runtime.GC() // two cycles reduces scheduler nondeterminism
_, ok := c.Get("key")
assert.False(t, ok)
}
v := &Thing{id: 1}
c.Put("key", v)
got, ok := c.Get("key")
require.True(t, ok)
assert.Equal(t, 1, got.id)
runtime.KeepAlive(v) // prevent v from being collected before Get returns
// ❌ Only reads are concurrent — misses write/write and read/write races
c.Put("key", v)
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() { defer wg.Done(); c.Get("key") }()
}
// ✅ Mix of concurrent reads and writes
var wg sync.WaitGroup
for i := range 10 {
wg.Add(1)
go func(i int) {
defer wg.Done()
c.Put(fmt.Sprintf("k%d", i%3), &Thing{id: i})
c.Get(fmt.Sprintf("k%d", i%3))
}(i)
}
wg.Wait()
// ❌ Only verifies a panic happens — message is part of the contract too
require.Panics(t, func() { c.Put("k", nil) })
// ✅ Locks in the contract
require.PanicsWithValue(t, "cache: value must be non-nil", func() {
c.Put("k", nil)
})
pool.Put / Release: have ALL callers finished using data derived from this object?os.CreateTemp + os.Rename, not a deterministic .tmp path?errgroup.SetLimit or semaphore in place?int64 / uint64 size or offset: validated and converted to int before make or slice?fs.ErrNotExist suppressed as a miss?_ = expr: is there a reason this error is intentionally ignored? Add a comment.TestFooZeroAllocs: does the assertion actually require ≤0?//go:noinline helper to drop the strong ref + runtime.KeepAlive after assertions?development
Team-based development workflow using experimental agent teams - INIT → WORKTREE → BRAINSTORM → PLAN → EXECUTE → REVIEW → COMPLETE
development
Implements code changes following plans and specifications
data-ai
Autonomous brainstorming agent for workflow orchestration
testing
Specialized testing agent for running tests and quality checks