Files
Ethan e99f7171e4 ci: require docs lint when docs change (#25608)
Move docs linting into the required CI umbrella and reuse the existing
`changes` job so docs lint runs when docs or CI files change, plus on
`main` as a backstop.

This is motivated by the docs lint failures on #25601. That PR touched
`.claude/docs/TESTING.md`; the standalone `Docs CI` workflow picked it
up because `docs-ci.yaml` used broad `**.md` matching, but local `pnpm
lint-docs` and `make lint` did not catch the same file because they only
scanned `docs/**` plus root `*.md`. The first failed Docs CI run
reported markdownlint errors in `.claude/docs/TESTING.md` (`MD040` and
`MD031`), and the next run reported a markdown table formatter failure
in the same file.

That mismatch is why this PR exists: prevent unrelated PRs from being
surprised by stale `.claude/docs/**` lint drift only after they happen
to touch one of those files. The local docs scripts now include
`.claude/docs/**`, and the old standalone `Docs CI` workflow is removed
so we do not maintain separate path-filter logic outside the required CI
workflow.

> Generated by mux, but reviewed by a human
2026-05-27 12:30:05 +10:00

20 KiB

Modern Go (1.18-1.26)

Reference for writing idiomatic Go. Covers what changed, what it replaced, and what to reach for. Respect the project's go.mod go line: don't emit features from a version newer than what the module declares. Check go.mod before writing code.

Go LSP Navigation

Use Go LSP tools first for backend code navigation:

  • Find definitions: mcp__go-language-server__definition symbolName
  • Find references: mcp__go-language-server__references symbolName
  • Get type info: mcp__go-language-server__hover filePath line column
  • Rename symbol: mcp__go-language-server__rename_symbol filePath line column newName

Code Comments

Code comments should be clear, well-formatted, and add meaningful context.

  • Comments are sentences and should end with periods or other appropriate punctuation.
  • Explain why, not what. The code itself should be self-documenting through clear naming and structure. Focus comments on non-obvious decisions, edge cases, or business logic.
  • Keep comment lines to 80 characters wide, including the comment prefix like // or #. When a comment spans multiple lines, wrap it naturally at word boundaries.
// Good: Explains the rationale with proper sentence structure.
// We need a custom timeout here because workspace builds can take several
// minutes on slow networks, and the default 30s timeout causes false
// failures during initial template imports.
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)

// Bad: Describes what the code does without punctuation or wrapping.
// Set a custom timeout
// Workspace builds can take a long time
// Default timeout is too short
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)

Avoid Unnecessary Changes

When fixing a bug or adding a feature, don't modify code unrelated to your task. Unnecessary changes make PRs harder to review and can introduce regressions.

  • Don't reword existing comments or code unless the change is directly motivated by your task.
  • Don't delete existing comments that explain non-obvious behavior.
  • When adding tests for new behavior, read existing tests first to understand what's covered. Add new cases for uncovered behavior. Edit existing tests as needed, but don't change what they verify.

How modern Go thinks differently

Generics (1.18): Design reusable code with type parameters instead of interface{} casts, code generation, or the sort.Interface pattern. Use any for unconstrained types, comparable for map keys and equality, cmp.Ordered for sortable types. Type inference usually makes explicit type arguments unnecessary (improved in 1.21).

Per-iteration loop variables (1.22): Each loop iteration gets its own variable copy. Closures inside loops capture the correct value. The v := v shadow trick is dead. Remove it when you see it.

Iterators (1.23): iter.Seq[V] and iter.Seq2[K,V] are the standard iterator types. Containers expose .All() methods returning these. Combined with slices.Collect, slices.Sorted, maps.Keys, etc., they replace ad-hoc "loop and append" code with composable, lazy pipelines. When a sequence is consumed only once, prefer an iterator over materializing a slice.

Error trees (1.20-1.26): Errors compose as trees, not chains. errors.Join aggregates multiple errors. fmt.Errorf accepts multiple %w verbs. errors.Is/As traverse the full tree. Custom error types that wrap multiple causes must implement Unwrap() []error (the slice form), not Unwrap() error, or tree traversal won't find the children. errors.AsType[T] (1.26) is the type-safe way to match error types. Propagate cancellation reasons with context.WithCancelCause.

Structured logging (1.21): log/slog is the standard structured logger. This project uses cdr.dev/slog/v3 instead, which has a different API. Do not use log/slog directly.

Replace these patterns

The left column reflects common patterns from pre-1.22 Go. Write the right column instead. The "Since" column tells you the minimum go directive version required in go.mod.

Old pattern Modern replacement Since
interface{} any 1.18
v := v inside loops remove it 1.22
for i := 0; i < n; i++ for i := range n 1.22
for i := 0; i < b.N; i++ (benchmarks) for b.Loop() (correct timing, future-proof) 1.24
sort.Slice(s, func(i,j int) bool{…}) slices.SortFunc(s, cmpFn) 1.21
wg.Add(1); go func(){ defer wg.Done(); … }() wg.Go(func(){…}) 1.25
func ptr[T any](v T) *T { return &v } new(expr) e.g. new(time.Now()) 1.26
var target *E; errors.As(err, &target) t, ok := errors.AsType[*E](err) 1.26
Custom multi-error type errors.Join(err1, err2, …) 1.20
Single %w for multiple causes fmt.Errorf("…: %w, %w", e1, e2) 1.20
rand.Seed(time.Now().UnixNano()) delete it (auto-seeded); prefer math/rand/v2 1.20/1.22
sync.Once + captured variable sync.OnceValue(func() T {…}) / OnceValues 1.21
Custom min/max helpers min(a, b) / max(a, b) builtins (any ordered type) 1.21
for k := range m { delete(m, k) } clear(m) (also zeroes slices) 1.21
Index+slice or SplitN(s, sep, 2) strings.Cut(s, sep) / bytes.Cut 1.18
TrimPrefix + check if anything was trimmed strings.CutPrefix / CutSuffix (returns ok bool) 1.20
strings.Split + loop when no slice is needed strings.SplitSeq / Lines / FieldsSeq (iterator, no alloc) 1.24
"2006-01-02" / "2006-01-02 15:04:05" / "15:04:05" time.DateOnly / time.DateTime / time.TimeOnly 1.20
Manual Before/After/Equal chains for comparison time.Time.Compare (returns -1/0/+1; works with slices.SortFunc) 1.20
Loop collecting map keys into slice slices.Sorted(maps.Keys(m)) 1.23
fmt.Sprintf + append to []byte fmt.Appendf(buf, …) (also Append, Appendln) 1.18
reflect.TypeOf((*T)(nil)).Elem() reflect.TypeFor[T]() 1.22
*(*[4]byte)(slice) unsafe cast [4]byte(slice) direct conversion 1.20
atomic.LoadInt64 / AddInt64 / StoreInt64 etc. atomic.Int64 (also Int32, Uint32, Uint64, Bool, Pointer[T]) 1.19
crypto/rand.Read(buf) + hex/base64 encode crypto/rand.Text() (one call) 1.24
Checking crypto/rand.Read error don't: return is always nil 1.24
time.Sleep in tests testing/synctest (deterministic fake clock) 1.24/1.25
json:",omitempty" on zero-value structs like time.Time{} json:",omitzero" (uses IsZero() method) 1.24
strings.Title golang.org/x/text/cases 1.18
net.IP in new code net/netip.Addr (immutable, comparable, lighter) 1.18
tools.go with blank imports tool directive in go.mod 1.24
runtime.SetFinalizer runtime.AddCleanup (multiple per object, no pointer cycles) 1.24
httputil.ReverseProxy.Director .Rewrite hook + ProxyRequest (Director deprecated in 1.26) 1.20
sql.NullString, sql.NullInt64, etc. sql.Null[T] 1.22
Manual ctx, cancel := context.WithCancel(…) + t.Cleanup(cancel) t.Context() (auto-canceled when test ends) 1.24
if d < 0 { d = -d } on durations d.Abs() (handles math.MinInt64) 1.19
Implement only TextMarshaler also implement TextAppender for alloc-free marshaling 1.24
Custom Unwrap() error on multi-cause errors Unwrap() []error (slice form; required for tree traversal) 1.20

New capabilities

These enable things that weren't practical before. Reach for them in the described situations.

What Since When to use it
cmp.Or(a, b, c) 1.22 Defaults/fallback chains: returns first non-zero value. Replaces verbose if a != "" { return a } cascades.
context.WithoutCancel(ctx) 1.21 Background work that must outlive the request (e.g. async cleanup after HTTP response). Derived context keeps parent's values but ignores cancellation.
context.AfterFunc(ctx, fn) 1.21 Register cleanup that fires on context cancellation without spawning a goroutine that blocks on <-ctx.Done().
context.WithCancelCause / Cause 1.20 When callers need to know WHY a context was canceled, not just that it was. Retrieve cause with context.Cause(ctx).
context.WithDeadlineCause / WithTimeoutCause 1.21 Attach a domain-specific error to deadline/timeout expiry (e.g. distinguish "DB query timed out" from "HTTP request timed out").
errors.ErrUnsupported 1.21 Standard sentinel for "not supported." Use instead of per-package custom sentinels. Check with errors.Is.
http.ResponseController 1.20 Per-request flush, hijack, and deadline control without type-asserting ResponseWriter to http.Flusher or http.Hijacker.
Enhanced ServeMux routing 1.22 "GET /items/{id}" patterns in http.ServeMux. Access with r.PathValue("id"). Wildcards: {name}, catch-all: {path...}, exact: {$}. Eliminates many third-party router dependencies.
os.Root / OpenRoot 1.24 Confined directory access that prevents symlink escape. 1.25 adds MkdirAll, ReadFile, WriteFile for real use.
os.CopyFS 1.23 Copy an entire fs.FS to local filesystem in one call.
os/signal.NotifyContext with cause 1.26 Cancellation cause identifies which signal (SIGTERM vs SIGINT) triggered shutdown.
io/fs.SkipAll / filepath.SkipAll 1.20 Return from WalkDir callback to stop walking entirely. Cleaner than a sentinel error.
GOMEMLIMIT env / debug.SetMemoryLimit 1.19 Soft memory limit for GC. Use alongside or instead of GOGC in memory-constrained containers.
net/url.JoinPath 1.19 Join URL path segments correctly. Replaces error-prone string concatenation.
go test -skip 1.20 Skip tests matching a pattern. Useful when running a subset of a large test suite.

Key packages

slices (1.21, iterators added 1.23)

Replaces sort.Slice, manual search loops, and manual contains checks.

Search: Contains, ContainsFunc, Index, IndexFunc, BinarySearch, BinarySearchFunc.

Sort: Sort, SortFunc, SortStableFunc, IsSorted, IsSortedFunc, Min, MinFunc, Max, MaxFunc.

Transform: Clone, Compact, CompactFunc, Grow, Clip, Concat (1.22), Repeat (1.23), Reverse, Insert, Delete, Replace.

Compare: Equal, EqualFunc, Compare.

Iterators (1.23): All, Values, Backward, Collect, AppendSeq, Sorted, SortedFunc, SortedStableFunc, Chunk.

maps (1.21, iterators added 1.23)

Core: Clone, Copy, Equal, EqualFunc, DeleteFunc.

Iterators (1.23): All, Keys, Values, Insert, Collect.

cmp (1.21, Or added 1.22)

Ordered constraint for any ordered type. Compare(a, b) returns -1/0/+1. Less(a, b) returns bool. Or(vals...) returns first non-zero value.

iter (1.23)

Seq[V] is func(yield func(V) bool). Seq2[K,V] is func(yield func(K, V) bool). Return these from your container's .All() methods. Consume with for v := range seq or pass to slices.Collect, slices.Sorted, maps.Collect, etc.

math/rand/v2 (1.22)

Replaces math/rand. IntN not Intn. Generic N[T]() for any integer type. Default source is ChaCha8 (crypto-quality). No global Seed. Use rand.New(source) for reproducible sequences.

log/slog (1.21)

slog.Info, slog.Warn, slog.Error, slog.Debug with key-value pairs. slog.With(attrs...) for logger with preset fields. slog.GroupAttrs (1.25) for clean group creation. Implement slog.Handler for custom backends.

Note: This project uses cdr.dev/slog/v3, not log/slog. The API is different. Read existing code for usage patterns.

Pitfalls

Things that are easy to get wrong, even when you know the modern API exists. Check your output against these.

Version misuse. The replacement table has a "Since" column. If the project's go.mod says go 1.22, you cannot use wg.Go (1.25), errors.AsType (1.26), new(expr) (1.26), b.Loop() (1.24), or testing/synctest (1.24). Fall back to the older pattern. Always check before reaching for a replacement.

slices.Sort vs slices.SortFunc. slices.Sort requires cmp.Ordered types (int, string, float64, etc.). For structs, custom types, or multi-field sorting, use slices.SortFunc with a comparator function. Using slices.Sort on a non-ordered type is a compile error.

for range n still binds the index. for range n discards the index. If you need it, write for i := range n. Writing for range n and then trying to use i inside the loop is a compile error.

Don't hand-roll iterators when the stdlib returns one. Functions like maps.Keys, slices.Values, strings.SplitSeq, and strings.Lines already return iter.Seq or iter.Seq2. Don't reimplement them. Compose with slices.Collect, slices.Sorted, etc.

Don't mix math/rand and math/rand/v2. They have different function names (Intn vs IntN) and different default sources. Pick one per package. Prefer v2 for new code. The v1 global source is auto-seeded since 1.20, so delete rand.Seed calls either way.

Iterator protocol. When implementing iter.Seq, you must respect the yield return value. If yield returns false, stop iteration immediately and return. Ignoring it violates the contract and causes panics when consumers break out of for range loops early.

errors.Join with nil. errors.Join skips nil arguments. This is intentional and useful for aggregating optional errors, but don't assume the result is always non-nil. errors.Join(nil, nil) returns nil.

cmp.Or evaluates all arguments. Unlike a chain of if statements, cmp.Or(a(), b(), c()) calls all three functions. If any have side effects or are expensive, use if/else instead.

Timer channel semantics changed in 1.23. Code that checks len(timer.C) to see if a value is pending no longer works (channel capacity is 0). Use a non-blocking select receive instead: select { case <-timer.C: default: }.

context.WithoutCancel still propagates values. The derived context inherits all values from the parent. If any middleware stores request-scoped state (deadlines, trace IDs) via context.WithValue, the background work sees it. This is usually desired but can be surprising if the values hold references that should not outlive the request.

Behavioral changes that affect code

  • Timers (1.23): unstopped Timer/Ticker are GC'd immediately. Channels are unbuffered: no stale values after Reset/Stop. You no longer need defer t.Stop() to prevent leaks.
  • Error tree traversal (1.20): errors.Is/As follow Unwrap() []error, not just Unwrap() error. Multi-error types must expose the slice form for child errors to be found.
  • math/rand auto-seeded (1.20): the global RNG is auto-seeded. rand.Seed is a no-op in 1.24+. Don't call it.
  • GODEBUG compat (1.21): behavioral changes are gated by go.mod's go line. Upgrading the version opts into new defaults.
  • Build tags (1.18): //go:build is the only syntax. // +build is gone.
  • Tool install (1.18): go get no longer builds. Use go install pkg@version.
  • Doc comments (1.19): support [links], lists, and headings.
  • go test -skip (1.20): skip tests by name pattern from the command line.
  • go fix ./... modernizers (1.26): auto-rewrites code to use newer idioms. Run after Go version upgrades.

Transparent improvements (no code changes)

Swiss Tables maps, Green Tea GC, PGO, faster io.ReadAll, stack-allocated slices, reduced cgo overhead, container-aware GOMAXPROCS. Free on upgrade.