diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-02 18:22:11 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-02 18:22:11 -0500 |
| commit | 3a06aff7eec20814f6b51b72691f4140668189c2 (patch) | |
| tree | 0dcfde239685ebeabea3ce941317f1dac5be8349 /languages | |
| parent | 0b07c15fb33ceaeec484dec9889c37098ec2e844 (diff) | |
| download | rulesets-3a06aff7eec20814f6b51b72691f4140668189c2.tar.gz rulesets-3a06aff7eec20814f6b51b72691f4140668189c2.zip | |
feat(go): build out the full Go language bundle
The Go bundle was coverage-slice-only. Because it shipped no rule files, sync-language-bundle.sh (which fingerprints a project's bundle by spotting one of its rule files in .claude/rules/) couldn't detect it, so the coverage slice it did ship never stayed in sync. Adding the rules is what makes the bundle sync-maintainable, which was the point.
Brought Go to the full tier, matching elisp:
- claude/rules/go.md and go-testing.md, the style and testing rules (table-driven tests, go test -race, errors.Is over message matching, how the coverage slice fits). These two are also the sync fingerprint.
- claude/hooks/validate-go.sh, a PostToolUse hook that runs gofmt and go vet on each edited .go file. go vet type-checks, so compile and syntax errors surface at edit time. It deliberately doesn't auto-run tests, since a package's tests can be slow or integration-tagged and shouldn't fire on every keystroke.
- claude/settings.json, Go permissions plus the hook wiring.
- githooks/pre-commit, a secret scan and a gofmt check on staged .go.
- CLAUDE.md, the seed.
validate-go.sh is TDD'd by scripts/tests/validate-go.bats: a clean file passes, gofmt and vet failures both block with the JSON payload, and non-go, missing, or empty paths are ignored. I updated install-lang.bats test 7, which asserted Go installs no CLAUDE.md, to check the full bundle instead. Verified with a real install into a throwaway project and a green make test.
Diffstat (limited to 'languages')
| -rw-r--r-- | languages/go/CLAUDE.md | 75 | ||||
| -rwxr-xr-x | languages/go/claude/hooks/validate-go.sh | 62 | ||||
| -rw-r--r-- | languages/go/claude/rules/go-testing.md | 166 | ||||
| -rw-r--r-- | languages/go/claude/rules/go.md | 82 | ||||
| -rw-r--r-- | languages/go/claude/settings.json | 85 | ||||
| -rwxr-xr-x | languages/go/githooks/pre-commit | 51 |
6 files changed, 521 insertions, 0 deletions
diff --git a/languages/go/CLAUDE.md b/languages/go/CLAUDE.md new file mode 100644 index 0000000..b50a05a --- /dev/null +++ b/languages/go/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +## Project + +Go project. Customize this section with your own description, layout, and conventions. + +**Typical layout:** +- `main.go` or `cmd/<name>/main.go` — entry points +- `internal/` — packages private to this module +- `pkg/` — packages intended for external import (only if you publish them) +- `<package>/<file>_test.go` — tests beside the code they exercise +- `testdata/` — fixtures (ignored by the build tool) + +## Build & Test Commands + +If the project has a Makefile, document targets here. Common pattern: + +```bash +make test # go test ./... +make test -- -run Pattern # match test names +make coverage # suite under -coverprofile + go tool cover -func +make coverage-summary # file-weighted total + files absent from the profile +make lint # gofmt check + go vet (+ staticcheck/golangci-lint if used) +make build # go build ./... +``` + +Direct equivalents: `go test ./...`, `go test -race ./...`, `go vet ./...`, +`go build ./...`, `gofmt -l .`. + +## Language Rules + +See rule files in `.claude/rules/`: +- `go.md` — code style and patterns +- `go-testing.md` — testing conventions (table-driven, `-race`, coverage) +- `verification.md` — verify-before-claim-done discipline + +## Git Workflow + +Commit conventions: see `.claude/rules/commits.md` (author identity, +no AI attribution, message format). + +Pre-commit hook in `githooks/` scans for secrets and runs `gofmt -l` on staged +`.go` files. Activate on a fresh clone with `git config core.hooksPath githooks`. + +## Problem-Solving Approach + +Investigate before fixing. When diagnosing a bug: +1. Read the relevant package and trace what actually happens +2. Identify the root cause, not a surface symptom +3. Write a failing test (a table row) that captures the correct behavior +4. Fix, then re-run tests with `go test -race ./...` + +## Testing Discipline + +TDD is the default: write a failing test before any implementation. If you +can't write the test, you don't yet understand the change. Table-driven tests +express the Normal/Boundary/Error categories; details in +`.claude/rules/go-testing.md`. + +## Editing Discipline + +A PostToolUse hook runs `gofmt` and `go vet` on every `.go` file after +Edit/Write/MultiEdit. `go vet` type-checks, so compile errors and suspicious +constructs surface at edit time — read them. Tests aren't auto-run on edit +(a package's tests can be slow or integration-tagged); run them with `make +coverage` or `go test`. + +## What Not to Do + +- Don't add features beyond what was asked +- Don't refactor surrounding code when fixing a bug +- Don't ignore a returned error to shorten a line +- Don't add comments to code you didn't change +- Don't create an interface or abstraction for a single implementation +- Don't commit credentials or API keys — the pre-commit hook catches common patterns but isn't a substitute for care diff --git a/languages/go/claude/hooks/validate-go.sh b/languages/go/claude/hooks/validate-go.sh new file mode 100755 index 0000000..c2c6ff1 --- /dev/null +++ b/languages/go/claude/hooks/validate-go.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Validate Go files after Edit/Write/MultiEdit. +# PostToolUse hook: receives tool-call JSON on stdin. +# +# On success: exit 0 silent. +# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude +# sees a structured error in its context, THEN exit 2 to block the tool +# pipeline. stderr still echoes the error for terminal visibility. +# +# Phase 1: gofmt — formatting must be clean. +# Phase 2: go vet — compile + suspicious-construct check on the file's package, +# run only when the file sits inside a module (go.mod found). go vet +# type-checks, so it surfaces compile and syntax errors too. +# +# Tests deliberately stop here: `go test` on the edited package can pull in slow +# or integration-tagged tests that shouldn't fire on every keystroke. Run them +# explicitly via `make coverage` / `go test`. + +set -u + +# Emit a JSON failure payload and exit 2. Arguments: +# $1 — short failure type (e.g. "GO VET FAILED") +# $2 — file path +# $3 — tool output (error body) +fail_json() { + local ctx + ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \ + | jq -Rs .)" + cat <<EOF +{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}} +EOF + printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2 + exit 2 +} + +f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')" +[ -z "$f" ] && exit 0 +[ "${f##*.}" = "go" ] || exit 0 +[ -f "$f" ] || exit 0 + +# No toolchain on this machine — nothing to validate, don't block the edit. +command -v gofmt >/dev/null 2>&1 || exit 0 + +# --- Phase 1: formatting --- +# gofmt -l prints the path when the file isn't gofmt-clean (and stays silent on +# a parse error — go vet catches those in Phase 2). Show the diff so the fix is +# obvious. +if [ -n "$(gofmt -l "$f" 2>/dev/null)" ]; then + fail_json "GOFMT: file is not gofmt-clean" "$f" "$(gofmt -d "$f" 2>&1)" +fi + +# --- Phase 2: vet (needs module context) --- +command -v go >/dev/null 2>&1 || exit 0 +dir="$(dirname "$f")" +gomod="$(cd "$dir" && go env GOMOD 2>/dev/null)" +if [ -n "$gomod" ] && [ "$gomod" != "/dev/null" ]; then + if ! out="$(cd "$dir" && go vet . 2>&1)"; then + fail_json "GO VET FAILED" "$f" "$out" + fi +fi + +exit 0 diff --git a/languages/go/claude/rules/go-testing.md b/languages/go/claude/rules/go-testing.md new file mode 100644 index 0000000..3b20efc --- /dev/null +++ b/languages/go/claude/rules/go-testing.md @@ -0,0 +1,166 @@ +# Go Testing Rules + +Applies to: `**/*_test.go` + +Implements the core principles from `testing.md`. All rules there apply here — +this file covers Go-specific patterns. + +## Framework: the standard `testing` package + +Use the standard library `testing` package. Reach for a third-party assertion +library (`testify`) only when a project already uses it; don't introduce it for +new code. Plain `if got != want { t.Errorf(...) }` is the idiom, and it keeps +the failure message under your control. + +Avoid full BDD frameworks (Ginkgo/Gomega) unless the project standardizes on +them — they obscure the standard `go test` failure output. + +## Table-Driven Tests Are the Default + +A table-driven test is how Go expresses the Normal / Boundary / Error +categories from `testing.md` in one place. Each row is a case; `t.Run` gives +each a named subtest so a failure points at the exact row. + +```go +func TestCartApplyDiscount(t *testing.T) { + tests := []struct { + name string + coupon string + want int + wantErr bool + }{ + {"normal percentage off", "SAVE10", 90, false}, // Normal + {"zero discount is a no-op", "SAVE0", 100, false}, // Boundary + {"expired coupon rejected", "EXPIRED", 0, true}, // Error + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplyDiscount(100, tt.coupon) + if (err != nil) != tt.wantErr { + t.Fatalf("ApplyDiscount() err = %v, wantErr = %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ApplyDiscount() = %d, want %d", got, tt.want) + } + }) + } +} +``` + +Use `t.Errorf` to record a failure and keep going (multiple assertions per +case); use `t.Fatalf` when continuing would panic or test nothing useful (a +`nil` you're about to dereference). + +### Pairwise for Parameter-Heavy Functions + +When the table would need dozens of rows to cover the combinations of three or +more parameters (feature flags × roles × shipping × payment), switch to +combinatorial coverage via `/pairwise-tests`. It generates a minimal matrix +hitting every 2-way interaction — usually 80-99% fewer rows than exhaustive — +which you paste straight back into the table. See `testing.md` § Combinatorial +Coverage for when to skip. + +## Run With the Race Detector + +Run the suite with `-race` for anything that touches goroutines, channels, or +shared state. The race detector catches data races that are invisible in a +plain run and flaky in production. + +``` +go test -race ./... +``` + +Wire `-race` into the project's `make test`. A passing race-free run is the bar +for concurrent code, not an optional extra. + +## Test Naming and Location + +- Tests live in `<file>_test.go` beside the code, in the same package + (`package foo`) for white-box tests, or `package foo_test` for black-box + tests that exercise only the exported API. Prefer black-box (`foo_test`) when + you're testing the contract; it keeps tests honest about what's public. +- Top-level test functions: `TestThing`, scenario carried by the subtest name + via `t.Run("expired coupon rejected", ...)`. +- Use `testing.T.Parallel()` on independent subtests to surface ordering + assumptions and speed the suite. Capture the range variable first + (`tt := tt`) on Go versions before 1.22. + +## Error Behavior, Not Error Text + +Assert the error's identity, not its prose. Production messages get reworded; +behavior doesn't. + +```go +got := Withdraw(account, 999) +if !errors.Is(got, ErrInsufficientFunds) { + t.Errorf("Withdraw() error = %v, want ErrInsufficientFunds", got) +} +``` + +Use `errors.Is` for sentinel errors and `errors.As` for typed errors. Match on +a substring only when a specific value must appear (the offending filename), +never on the whole message. This mirrors `testing.md`'s error-behavior rule. + +## Fixtures: `testdata/` and Golden Files + +- Put fixture inputs in a `testdata/` directory — the Go tool ignores it for + builds, and it travels with the package. +- For large expected outputs, use the golden-file pattern: compare against + `testdata/<name>.golden` and regenerate with a `-update` flag. + +```go +var update = flag.Bool("update", false, "update golden files") +// ... if *update { os.WriteFile(golden, got, 0o644) } +``` + +- Use `t.TempDir()` for scratch directories and `t.Cleanup()` for teardown; + both are removed automatically and survive `t.Parallel()`. + +## Mocking at Boundaries — Via Interfaces + +Go's interfaces are the seam. Define a small interface the code under test +depends on, and pass a fake in the test. Don't mock concrete types you own. + +### Mock these (external boundaries): +- Network: use `net/http/httptest.Server` for HTTP clients, not a mocked + `http.Client` transport you hand-build. +- Time: inject a `func() time.Time` clock rather than calling `time.Now()` + directly in business logic. +- Filesystem: accept an `fs.FS` (or `io.Reader`/`io.Writer`) so a test can pass + `fstest.MapFS` or a buffer. +- Third-party service clients: depend on a narrow interface the package + defines, not the vendor's concrete client. + +### Never mock these (internal domain): +- Concrete structs and methods you own — call them directly with real inputs. +- Pure functions (parsing, encoding, calculation) — those are the work. +- The standard library's own behavior. + +If a test needs an elaborate fake to stand in for your own code, that's a +design signal: extract a smaller interface or split the function (see +`testing.md` § If Tests Are Hard to Write). + +## Measuring Coverage — `make coverage-summary` + +The bundle ships a coverage summary at `.claude/scripts/coverage-summary.go` +plus a Makefile fragment (`coverage-makefile.txt`) with `coverage` and +`coverage-summary` targets. After `make coverage` runs the suite with +`-coverprofile` and prints `go tool cover -func`'s per-function table, `make +coverage-summary` prints a file-weighted project number and every source file +absent from the profile. + +The number to watch is that missing-file count. `go test ./...` lists in-module +packages in the profile (at 0% when untested), so the missing list is usually +empty for in-module code — it earns its keep on build-tagged files and dirs +outside the `./...` compilation. The summary weights by file and counts an +absent file as 0%, so untested code stays visible instead of being averaged +away. It doesn't reimplement the per-function table — `go tool cover -func` +already prints that. Copy the fragment's targets into your own Makefile to +adopt it; the bundle never edits your Makefile. + +## What the Validate Hook Already Enforces + +A PostToolUse hook runs `gofmt` and `go vet` on every edited `.go` file (see +the bundle's `CLAUDE.md`). Formatting and compile errors are caught at edit +time, so tests don't need to re-assert them — write tests for behavior, not for +"it compiles." diff --git a/languages/go/claude/rules/go.md b/languages/go/claude/rules/go.md new file mode 100644 index 0000000..5415a6a --- /dev/null +++ b/languages/go/claude/rules/go.md @@ -0,0 +1,82 @@ +# Go Code Rules + +Applies to: `**/*.go` + +Go-specific style and structure. Pairs with `go-testing.md` for tests and the +generic `verification.md` / `commits.md` rules. When in doubt, defer to +[Effective Go](https://go.dev/doc/effective_go) and the +[Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) wiki. + +## Formatting Is Not a Choice + +All code is `gofmt`-clean. The bundle's PostToolUse hook runs `gofmt` and `go +vet` on every edit and blocks on a violation, and the pre-commit hook re-checks +staged files. Don't hand-format; let the tool decide. Use `goimports` (or your +editor's organize-imports) to keep the import block grouped and pruned. + +## Errors + +- Return errors; don't panic across an API boundary. `panic` is for genuinely + unrecoverable, programmer-error states (a corrupt invariant), not for control + flow or expected failures. +- Wrap with context as an error travels up: `fmt.Errorf("load config %s: %w", + path, err)`. The `%w` verb preserves the chain for `errors.Is` / `errors.As`. +- Handle every error where it occurs. Don't assign to `_` to silence one unless + you can state why it's safe in a comment. +- Define sentinel errors (`var ErrNotFound = errors.New("not found")`) or typed + errors when callers need to branch on the failure; callers match with + `errors.Is` / `errors.As`, never on the message string. +- Check errors before using the other return value — the value is unspecified + when `err != nil`. + +## Naming and Shape + +- Package names are short, lowercase, no underscores or camelCase; the name is + part of every call site (`http.Client`, not `httputil.HTTPClient`). Avoid + stutter: `chat.Message`, not `chat.ChatMessage`. +- Exported identifiers carry a doc comment that begins with the identifier + name. Unexported helpers earn a comment when the why isn't obvious. +- Accept interfaces, return concrete types. Keep interfaces small — one or two + methods — and define them in the consuming package, not alongside the + implementation. +- Avoid naked returns except in very short functions; named returns that travel + more than a few lines hide what's being returned. + +## Concurrency + +- Pass `context.Context` as the first parameter (`ctx context.Context`) through + any call chain that does I/O or can block; honor cancellation. Never store a + context in a struct. +- A goroutine needs a defined lifecycle — know who stops it and how. Don't + launch one without a way for it to exit (a closed channel, a cancelled + context, a `sync.WaitGroup`). +- Share memory by communicating (channels) or guard it (`sync.Mutex`); don't do + both for the same state. Run concurrent code under `go test -race`. + +## Structure + +- Keep functions focused. A function that fetches, parses, decodes, and saves + is four functions; the test difficulty in `go-testing.md` is the tell. +- Zero values should be useful where you can manage it (`bytes.Buffer`, + `sync.Mutex` need no constructor). When construction must validate, provide a + `NewThing` that returns `(*Thing, error)`. +- Don't add an interface, generic, or abstraction for a single implementation. + Add it when the second caller arrives, not in anticipation. +- `defer` cleanup (`f.Close()`, `mu.Unlock()`) right after acquiring the + resource, so the release is visible next to the acquire. + +## Dependencies + +- Keep `go.mod` tidy: run `go mod tidy` after changing imports, commit the + resulting `go.mod` and `go.sum` together. +- Prefer the standard library. Pull in a dependency when it earns its + maintenance and supply-chain cost, not for a function you could write in a + few lines. + +## What Not to Do + +- Don't ignore a returned error to make the line shorter. +- Don't reach for `interface{}` / `any` when a concrete type or a small + generic constraint says what you mean. +- Don't refactor surrounding code while fixing a bug — keep the diff scoped. +- Don't add comments restating what the code already says; comment the why. diff --git a/languages/go/claude/settings.json b/languages/go/claude/settings.json new file mode 100644 index 0000000..1bd333a --- /dev/null +++ b/languages/go/claude/settings.json @@ -0,0 +1,85 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "permissions": { + "allow": [ + "Bash(make)", + "Bash(make help)", + "Bash(make targets)", + "Bash(make test)", + "Bash(make test *)", + "Bash(make coverage)", + "Bash(make coverage-summary)", + "Bash(make lint)", + "Bash(make build)", + "Bash(go build)", + "Bash(go build *)", + "Bash(go test)", + "Bash(go test *)", + "Bash(go vet)", + "Bash(go vet *)", + "Bash(go run *)", + "Bash(go list *)", + "Bash(go env)", + "Bash(go env *)", + "Bash(go version)", + "Bash(go mod tidy)", + "Bash(go mod verify)", + "Bash(go mod download)", + "Bash(go tool cover *)", + "Bash(gofmt *)", + "Bash(gofumpt *)", + "Bash(staticcheck *)", + "Bash(golangci-lint run *)", + "Bash(git status)", + "Bash(git status *)", + "Bash(git diff)", + "Bash(git diff *)", + "Bash(git log)", + "Bash(git log *)", + "Bash(git show)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(git branch)", + "Bash(git branch -v)", + "Bash(git branch -a)", + "Bash(git branch --list *)", + "Bash(git remote)", + "Bash(git remote -v)", + "Bash(git remote show *)", + "Bash(git ls-files *)", + "Bash(git rev-parse *)", + "Bash(git cat-file *)", + "Bash(git stash list)", + "Bash(git stash show *)", + "Bash(jq *)", + "Bash(date)", + "Bash(date *)", + "Bash(which *)", + "Bash(file *)", + "Bash(ls)", + "Bash(ls *)", + "Bash(wc *)", + "Bash(du *)", + "Bash(readlink *)", + "Bash(realpath *)", + "Bash(basename *)", + "Bash(dirname *)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-go.sh" + } + ] + } + ] + } +} diff --git a/languages/go/githooks/pre-commit b/languages/go/githooks/pre-commit new file mode 100755 index 0000000..a3d6f3f --- /dev/null +++ b/languages/go/githooks/pre-commit @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Pre-commit hook: secret scan + gofmt check on staged .go files. +# Use `git commit --no-verify` to bypass for confirmed false positives. + +set -u + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# --- 1. Secret scan --- +# Patterns for common credentials. Scans only added lines in the staged diff. +SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])' + +secret_hits="$(git diff --cached -U0 --diff-filter=AM \ + | grep '^+' | grep -v '^+++' \ + | grep -iEn "$SECRET_PATTERNS" || true)" + +if [ -n "$secret_hits" ]; then + echo "pre-commit: potential secret in staged changes:" >&2 + echo "$secret_hits" >&2 + echo "" >&2 + echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2 + echo "bypass with: git commit --no-verify" >&2 + exit 1 +fi + +# --- 2. gofmt check on staged .go files --- +# gofmt -l lists files that aren't gofmt-clean. Skip generated and vendored +# files the same way the rest of the toolchain does. +staged_go="$(git diff --cached --name-only --diff-filter=AM \ + | grep '\.go$' \ + | grep -vE '(^|/)vendor/' || true)" + +if [ -n "$staged_go" ] && command -v gofmt >/dev/null 2>&1; then + unformatted="" + while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$f" ] || continue + if [ -n "$(gofmt -l "$f" 2>/dev/null)" ]; then + unformatted="${unformatted}${f}"$'\n' + fi + done <<< "$staged_go" + + if [ -n "$unformatted" ]; then + printf 'pre-commit: gofmt check failed — these files need `gofmt -w`:\n\n%s\n' "$unformatted" >&2 + echo "Run: gofmt -w <file> (or your editor's format-on-save), then re-stage." >&2 + exit 1 + fi +fi + +exit 0 |
