byob-go-cli

Memories

One-paragraph idiomatic tips. bd prime auto-injects all of these into agent sessions; this page is the human-browsable mirror.

atomic-rename-samedir

For atomic file writes, the temp file MUST live in the same directory as the target — rename(2) is atomic only within a single filesystem, so a rename from /tmp/foo to ~/.config/foo is a non-atomic cross-filesystem copy. Use os.CreateTemp(filepath.Dir(target), ".tmp-*"), write, sync, rename. Wrap in a WriteFileAtomic helper.

blank-ident-assert

Declare var _ Iface = (*Concrete)(nil) at package scope wherever a concrete type implements an interface you care about. The assignment is type-checked at compile time, so any drift (renamed method, wrong signature, new interface method) fails the build at that line instead of at a distant runtime call site. Zero runtime cost.

cache-evict-only-on-error

A process-lifetime memoizing cache that evicts entries only when the build function errors must never swallow a transient failure into an empty payload — doing so caches the failure as a success and pins it until restart. Every build closure has to distinguish "genuinely no rows" (return the empty result; caching it is correct) from "backend failed" (return the error so the entry is evicted and the next call retries). The easy miss is treating sql.ErrNoRows and a connection error the same way; the contract belongs on the store methods too — multi-row queries return (empty, nil) for no data and reserve non-nil errors for real failures.

cancellable-sleep

Never bare time.Sleep on any path that should respond to cancellation — backoffs, retries, poll loops. Use t := time.NewTimer(d); defer t.Stop() and select { case <-t.C: case <-ctx.Done(): }, ideally behind an injected clock seam so tests skip real waits. A bare Sleep makes Ctrl-C hang for the full duration: field-tested against quota backoffs of up to six hours, which stayed interruptible only because the sleep selected on ctx.Done().

context-first-param

ctx context.Context is always the first parameter of any function that takes one, conventionally named ctx, never stored as a struct field. Storing ctx on a struct ties every method's cancellation lifetime to whoever populated the field, defeats errgroup.WithContext's derived-ctx pattern, and tempts closures to capture a stale ctx. The byob-lifecycle.1 decision threads ctx through every runFunc — this is the underlying rule.

coverage-is-not-a-goal

When handed "raise coverage to N%", reframe it before writing anything: "which user-visible behaviors are currently unprotected by tests?" Then test those contracts. Never write a test whose sole purpose is to move the percentage — no call-and-ignore-the-return, no flag-parsing tables that assert nothing about behavior. Every test in a plan should be nameable by the behavior it protects ("export emits finite values for at least one row"), not the lines it covers. Coverage is a lagging indicator of the real goal; treat a low number as a prompt to find unprotected behavior, not as the deliverable.

decisions-lookup

byob ships its library under a custom byob issue type — reference material for architectural choices you make now (error patterns, command shapes, config loaders, interface seams). Browse the ~20 category roots with bd list --type=byob --no-parent; drill in with bd list --type=byob -l errors (or any other category label); then bd show <id> for the full Problem / Idea / Tradeoffs / Sketch. Use bd ready --exclude-type=byob to keep the library out of the ready-work list — your own decisions and epics still surface because they use the built-in types.

byob beads are reference material, not work items: never claim or close a byob-* bead — closed beads drop out of default listings, so closing one hides that decision from every future session. Only your project's own task beads get claimed and closed.

byob beads are preferences, not contracts. Apply them when writing new code. Existing code that diverges might or might not be a bug — assess case-by-case; don't reflexively migrate just because something doesn't follow a byob idiom. File a task bead if the gap is worth tracking. And don't build anything — tests, lints, CI gates, hooks, runtime asserts — that fails when a byob decision is violated. They're idioms for new code, not invariants to enforce.

defer-unlock

After acquiring a mutex, defer the release on the very next line: mu.Lock(); defer mu.Unlock(). Same rule for RUnlock, channel closes, file Close, and any "must release" pair — adjacent placement makes the unlock visible at the lock site and impossible to forget on early returns or panics. The tx.Rollback exception: defer the rollback then call tx.Commit() before return; rollback after a successful commit is a documented no-op in database/sql, so the defer is safe.

doc-comment-shape

Doc comments on exported declarations start with the identifier name and form a complete sentence: // Store persists items to a backing SQLite file. not // This struct holds the store. godoc and IDE hover tooltips render the comment verbatim, so a comment that doesn't name what it documents loses its anchor when read out of context. Same shape for package comments: // Package store persists items to disk.

embed-staleness

When a go:embed input is itself a build artifact — a WASM binary, generated assets, compiled templates — a bare go build silently embeds whatever stale file is on disk; only the Makefile knows the dependency edge, and Go gives no warning. Make the artifact target a prerequisite of make build, and state in CLAUDE.md/README that a bare go build can ship stale bytes for that path. A checked-in manifest test (expected embedded filenames/sizes) or a version stamp inside the artifact turns "quietly stale" into a loud failure.

errors-is-as

Compare wrapped errors with errors.Is(err, sentinel) for sentinel checks and errors.As(err, &target) to extract a typed error — never ==, never raw type assertion. Once any layer wraps with %w (per errors-wrap-w), err == ErrFoo returns false even when errors.Is(err, ErrFoo) returns true; the type-assertion form e, ok := err.(*MyErr) has the same failure mode. errors.As walks the chain — pair with errors.Is once %w-wrapping is in the codebase.

errors-message-style

Error messages start with a lowercase letter, have no trailing punctuation, and contain no newlines or tabs. Errors get wrapped into larger sentences — fmt.Errorf("loading config: %w", err) composes into "loading config: reading foo.toml: no such file or directory", clean without mid-sentence capitals or stray periods. Multi-line remediation text belongs in an ErrHint wrapper or on ErrOut, not inside the error value.

errors-wrap-w

Always wrap errors with %w, never %v or %s: fmt.Errorf("reading %s: %w", path, err). %w preserves the chain so errors.Is/errors.As can find the underlying error; %v stringifies and silently severs it. Printed output is identical — the only difference is machine-readable introspection. Use %v only in log messages you're building but not returning.

goroutine-exit-path

Every go func() has a documented exit path: ctx cancellation via select { case <-ctx.Done(): return }, a sync.WaitGroup the parent calls Wait on, a channel close that breaks the inner loop, or natural completion of a bounded loop. No fire-and-forget. A goroutine blocked on an unbuffered send or receive is invisible to the GC and lives forever. byob's default fanout is errgroup.Group (byob-lifecycle.3); the rule applies to any goroutine.

got-want-order

Test failure messages put got before want: t.Errorf("Foo(%v) = %v, want %v", in, got, want). The testing package's diff output, IDEs, and reader habit all assume that order; reversing to "expected vs actual" works locally but breaks tooling. Mnemonic: "got is what you got, want is what you wanted." Note that go-cmp inverts at the call site — cmp.Diff(want, got) — because the diff renders as -want +got; the wrapping message still reads got-then-want.

initialism-casing

Initialisms (URL, HTTP, ID, JSON, API) keep consistent case in identifiers: appID not appId, serveHTTP not serveHttp, URLParser not UrlParser. Matches the stdlib (http.ServeHTTP, url.URL). The trap is copy-pasting from JS/Java/C# corpora where userId and urlParser read as natural — Go's stdlib has trained readers to expect the all-or-nothing form, and inconsistency stands out.

no-blank-error-discard

Don't write _ = err or _, _ = io.Copy(dst, src) to silence an error. Either handle it (return, log, fall back), or annotate the discard with a comment explaining why the error is provably safe to ignore: _ = f.Close() // best-effort close on read-only file. Silent discards are how production bugs hide in plain sight — every one is either a missed return path or worth one line of justification.

no-get-prefix

Field accessors omit the Get prefix: func (u *User) Name() string, not GetName(). Setters keep Set because func SetName(s string) has no idiomatic alternative. Action verbs are not accessors and keep their natural name: s3.GetObject is correct because it performs an RPC, not a field read. Mental model: Counts() is a noun (the count); GetCounts() is a Java-flavored method call.

pass-by-value-default

Don't pass pointers to function arguments just to save a copy. Small types — int, string, time.Time, structs of a few words, slices, maps — go by value. Passing *string adds an indirection, can force the value to escape to the heap, and signals "this function might mutate me" that you don't mean. Reach for a pointer when the function genuinely mutates the receiver, when the struct is large enough that copying matters (proto messages, big configs), or when nil encodes "absent."

principles-dry-kiss-solid

Duplicate twice before abstracting. Three similar lines is better than a premature interface or helper — the right abstraction is usually visible only after the second or third instance lands. The byob decisions already encode the abstractions worth pulling forward (Factory DI, narrow Options, small interfaces, semantic error types), so reach for them when the shape matches, not because a named principle (KISS, YAGNI, DRY, SOLID) demands it. Treat those names as tiebreakers during refactor, not constraints during design — invoking them up front tends to push toward more structure rather than less.

prompter-tty-check

Every Prompter method must check IO.IsStdinTTY() first. If stdin is not a terminal (CI, piped input, nohup'd background process), return the sentinel ErrNotTTY instead of prompting. Callers surface a clear error ("pass --yes to run non-interactively") or branch on errors.Is(err, prompt.ErrNotTTY). Failure mode to avoid: a prompt library reads EOF, interprets it as "no," and silently skips a destructive action. Loud failure beats silent wrong answer.

quote-strings-in-errors

Format user-supplied strings in errors and log messages with %q not %s: fmt.Errorf("unknown subcommand %q", name). %q wraps the value in Go-syntax quotes and escapes non-printables — empty strings render as "" instead of vanishing into mid-sentence whitespace, trailing newlines surface as \n, and a quoted "--help" doesn't get visually merged with surrounding text.

receiver-name

Method receiver names are a 1–2 letter abbreviation of the type, used consistently across every method on that type — func (s *Store) ... everywhere, never mixing in func (st *Store) or generic this/self/me. Short receivers read like math; consistent receivers let you scan a type's methods without re-parsing the binding name.

sync-oncevalue

Use sync.OnceValue[T] (Go 1.21+) to wrap an expensive lazy factory instead of hand-rolling sync.Once + captured vars. First call runs the underlying function and caches the return; subsequent calls return the cache. sync.OnceValues[A, B] handles the two-return form, which is what factory closures usually want: func() (Store, error). Errors are sticky — a failed open won't magically succeed on retry, almost always what you want for a factory.

test-cleanup

Use t.Cleanup(func()) instead of bare defer for test teardown. It runs LIFO even on panic, composes cleanly with t.Run subtests, and lives next to the resource it's tearing down rather than at the top of the test. Cleanups registered inside a helper stay attached to the test even after the helper returns — defer can't do that. Subtests inherit t and can register their own cleanups independently.

test-helper

Call t.Helper() as the first line of every test helper function. The testing framework skips helper frames when reporting failure locations, so a failed assertion points at the test that called the helper instead of at the helper's internals. Skip it only when debugging the helper itself.

test-tempdir

Use t.TempDir() for any test that touches the filesystem: it creates a per-test isolated directory and auto-cleans after the test ends. Write fixtures inline with os.WriteFile rather than maintaining a committed testdata/ tree — tests stay hermetic, parallel-safe, and the fixture and assertion read on the same screen. Reserve testdata/ for truly static reference files (golden outputs, sample binary inputs).

typed-string-enums-sql

For enums that cross the SQL or JSON boundary, prefer a typed string — type Kind string with const values — over iota ints. A typed string round-trips database/sql and encoding/json natively: no Scanner/Valuer/MarshalJSON ceremony, stored rows stay human-readable in the DB, and there's no zero-value poisoning where an uninitialized field silently means the first enum variant. Prefix the consts (KindRoads, KindParking) when bare names would collide with type names in the package — the stdlib's http.MethodGet is the pattern. Validate the value at the boundary per validate-at-boundaries.

validate-at-boundaries

Validate user input once, at the boundary — Options.Validate() per byob-input-validation.5, or the config-parse step per byob-input-validation.2. Internal callers can then trust what they receive; don't re-check args in every helper. Defensive validation deep in internal code rots into noise, hides real bugs by making the validation graph hard to read, and quietly assumes the rest of the codebase is untrusted when it isn't.