diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1562193..828060c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "go-coding", - "version": "0.4.1", + "version": "0.5.0", "description": "Idiomatic Go coding standards for AI assistants — formatting, errors, concurrency, testing, layout.", "author": { "name": "Cadasto B.V.", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 25d426a..7b9a729 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "go-coding", - "version": "0.4.1", + "version": "0.5.0", "description": "Idiomatic Go coding standards for AI assistants — formatting, errors, concurrency, testing, layout.", "author": { "name": "Cadasto B.V.", diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 45a79b3..e995a3a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -8,18 +8,28 @@ on: jobs: validate: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ['1.26.x', '1.27.x'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.x' - # The floor-minor Go toolchain activates the validator's Fixer-column check - # (go tool fix help is the authority for what `go fix` ships). Keep this minor + # The floor-minor (1.26.x) leg activates the validator's Fixer-column check + # (go tool fix help is the authority for what `go fix` ships); keep that minor # in step with GO_FLOOR_MINOR in scripts/validate.py and the documented baseline. + # The 1.27.x leg proves the plugin also validates on the next Go version — its + # Fixer-column check soft-skips (a note, not a failure) since it isn't the floor. - uses: actions/setup-go@v5 with: - go-version: '1.26.x' + go-version: ${{ matrix.go-version }} cache: false # no go.mod — nothing to cache # CI is strict and deterministic: Python is guaranteed here, so run the # validator directly (the scripts/validate.sh graceful skip is for local use). - run: python3 scripts/validate.py + # A green validator proves the tree is valid, not that the checks still check. + - run: python3 scripts/validate.py --selftest + # Bash-only hook test harness; runs on both matrix legs. + - run: ./scripts/hooks-test.sh diff --git a/AGENTS.md b/AGENTS.md index f3686b0..2ce8cee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This file provides guidance to AI coding assistants (Claude Code, Cursor, and co The **Go Coding Plugin** is an AI plugin by Cadasto B.V. that teaches AI coding assistants **idiomatic Go coding standards** — formatting, naming, error handling, concurrency, testing, and project layout — through skills, commands, agents, hooks, and Cursor rules. It targets **both Claude Code and Cursor** from a single shared component set. -> **Current status — v0.4.0.** A complete dual-host (Claude Code + Cursor) Go-standards set, baselined on **Go 1.26.4+** as a hard floor (no fallback guidance for 1.25 or older; version annotations remain as provenance), that validates clean (`./scripts/validate.sh` + `claude plugin validate .`): the auto-invoked `go-coding` **router** skill; the focused standards skills `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-linting`, `go-layout`; the read-only `go-reviewer` agent; the user-invoked `/go-explain` and `/go-lint-setup` skills; a shipped `references/golangci.v2.yml`; the `rules/go-context.mdc` Cursor rule; and host-agnostic `session-start` + `format-on-save` hooks. Do not assume a file is present because it is documented here — check first. +> **Current status — v0.5.0.** A complete dual-host (Claude Code + Cursor) Go-standards set, baselined on **Go 1.26.4+** (Go 1.27 supported; its additions are flagged as hints) as a hard floor (no fallback guidance for 1.25 or older; version annotations remain as provenance), that validates clean (`./scripts/validate.sh` + `claude plugin validate .`): the auto-invoked `go-coding` **router** skill; the focused standards skills `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-layout`; the report-only `go-reviewer` agent; the user-invoked `/go-lint-setup` skill; a shipped `references/golangci.v2.yml`; the `rules/go-context.mdc` Cursor rule; and host-agnostic `session-start` + `format-on-save` + `skill-nudge` hooks. Do not assume a file is present because it is documented here — check first. ## Domain Context @@ -30,25 +30,26 @@ When a recommendation derives from one of the above, attribute it explicitly and This repo supports **both Claude Code and Cursor**. Shared assets (skills, commands, agents) are consumed by both hosts; host-specific manifests and hook configs are kept separate. - **Claude manifest**: `.claude-plugin/plugin.json` — `name`, `version`, `description`, `author` (an **object** `{name, url}` — `claude plugin validate` rejects a string), `license`, `repository`, `keywords`. Claude Code discovers components from the **default folders** (`skills/`, `commands/`, `agents/`, `hooks/`) automatically; no explicit path map is needed. -- **Cursor manifest**: `.cursor-plugin/plugin.json` — same metadata **plus** explicit top-level path keys (`skills`, `rules`, `agents`, `commands`, `hooks`). No `mcpServers` — this plugin has no MCP backend. Keep `name`/`version`/`description`/`author` identical to the Claude manifest. -- **Skills**: `skills//SKILL.md` — shared by both hosts. Shipped: `go-coding` (auto-invoked router) plus the focused, load-on-use `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-linting`, `go-layout` standards skills. -- **Slash commands** are authored as **user-invoked skills** (`skills//SKILL.md` with `argument-hint` + `allowed-tools`), not the legacy `commands/` folder — both yield a `/` command, but the skills layout is preferred (current `plugin-dev` guidance). Keep the surface small; put multi-step workflows in auto-invoked skills. Shipped: `/go-explain` (idiom/standard lookup) and `/go-lint-setup` (scaffold the golangci-lint v2 config). -- **Agents**: `agents/.md` — context-isolated specialists. Shipped: `go-reviewer` (read-only Go diff/file reviewer applying the review-heuristics catalog; `tools:` not `allowed-tools:`). +- **Cursor manifest**: `.cursor-plugin/plugin.json` — same metadata **plus** explicit top-level path keys — `skills`, `agents`, `rules`, `hooks` (a `commands` key would go here too, but this plugin ships no `commands/` folder: `/go-lint-setup` is a user-invoked skill). No `mcpServers` — this plugin has no MCP backend. Keep `name`/`version`/`description`/`author` identical to the Claude manifest. +- **Skills**: `skills//SKILL.md` — shared by both hosts. Shipped: `go-coding` (auto-invoked router) plus the focused, load-on-use `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-layout` standards skills. `go-linting` was merged into `go-lint-setup` and removed in 0.5.0. +- **Slash commands** are authored as **user-invoked skills** (`skills//SKILL.md` with `argument-hint` + `allowed-tools`), not the legacy `commands/` folder — both yield a `/` command, but the skills layout is preferred (current `plugin-dev` guidance). Keep the surface small; put multi-step workflows in auto-invoked skills. Shipped: `/go-lint-setup` (scaffold the golangci-lint v2 config). `/go-explain` was removed in 0.5.0 — a one-shot lookup the focused skills already answer. +- **Agents**: `agents/.md` — context-isolated specialists. Shipped: `go-reviewer` (report-only Go diff/file reviewer applying the review-heuristics catalog; `tools:` not `allowed-tools:`). - **Cursor rules**: `rules/*.mdc` — Cursor-only rule guidance with frontmatter (`description`, `alwaysApply`, `globs`, e.g. `globs: ["**/*.go"]`), referenced by the Cursor manifest's `rules` path. Shipped: `rules/go-context.mdc` (mirrors the `go-coding` router). -- **Claude hooks**: `hooks/hooks.json` — object `{ "hooks": { "SessionStart": [...], "PostToolUse": [...] } }`; use `${CLAUDE_PLUGIN_ROOT}` in command paths. Present — wires `session-start.sh` (`SessionStart`) and `format-on-save.sh` (`PostToolUse`, `matcher: "Write|Edit"`). -- **Cursor hooks**: `hooks/cursor-hooks.json` — object `{ "hooks": { "sessionStart": [...], "afterFileEdit": [...] } }`; the command runs from the plugin root (a **workspace-relative** path, **not** `${CLAUDE_PLUGIN_ROOT}`). Present — wires `session-start.sh` (`sessionStart`) and `format-on-save.sh` (`afterFileEdit`). -- **Shared hook scripts**: `hooks/session-start.sh` — detects `go.mod` / `*.go`, prints one Go-standards context line, exits 0 always. `hooks/format-on-save.sh` — after a `*.go` Write/Edit, runs `gofumpt -w` (or `gofmt -w -s`) on that single file; resolves the path from `$CLAUDE_FILE_PATH` or the stdin tool-payload JSON, host-only, silent no-op if no formatter is installed, exits 0 always. Both host-agnostic so either manifest can invoke them. Present. +- **Claude hooks**: `hooks/hooks.json` — object `{ "hooks": { "SessionStart": [...], "PostToolUse": [...] } }`; use `${CLAUDE_PLUGIN_ROOT}` in command paths. Present — wires `session-start.sh` (`SessionStart`) and `format-on-save.sh` + `skill-nudge.sh` (`PostToolUse`, `matcher: "Write|Edit"`). +- **Cursor hooks**: `hooks/cursor-hooks.json` — object `{ "hooks": { "sessionStart": [...], "afterFileEdit": [...] } }`; the command runs from the plugin root (a **workspace-relative** path, **not** `${CLAUDE_PLUGIN_ROOT}`). Present — wires `session-start.sh` (`sessionStart`) and `format-on-save.sh` + `skill-nudge.sh` (`afterFileEdit`). +- **Shared hook scripts**: `hooks/session-start.sh` — detects `go.mod` / `*.go`, prints one Go-standards context line, exits 0 always. `hooks/format-on-save.sh` — after a `*.go` Write/Edit, runs `gofumpt -w` (or `gofmt -w -s`) on that single file; resolves the path from `$CLAUDE_FILE_PATH` or the stdin tool-payload JSON, host-only, silent no-op if no formatter is installed, exits 0 always. `hooks/skill-nudge.sh` — after a `*.go` Write/Edit, names ONE matching go-coding skill for that edit, once per skill per session; delivered as a hook `systemMessage` under Claude Code, a plain line under Cursor; exits 0 always. All three host-agnostic so either manifest can invoke them. Present. - **MCP config** *(optional, not present)*: `.mcp.json` — only if the plugin later integrates an MCP server. There is no companion MCP server today; do not reference one. -- **Validation**: `scripts/validate.sh` wraps `scripts/validate.py` to check both manifests, dual-host parity, declared component paths, kebab-case names, hook-config JSON, skill/command/agent frontmatter (**agents must use `tools:` not `allowed-tools:`** — flagged as an error), and two *advice == tooling* invariants: every linter taught in a component is enabled in `references/golangci.v2.yml`, and (when a floor-minor Go toolchain is on PATH — CI installs `1.26.x`, locally it soft-skips) the `go-idioms` Fixer column matches `go tool fix help`. The Python is stdlib-only. `.github/workflows/validate.yml` pins Python + Go and runs the validator strictly. +- **Validation**: `scripts/validate.sh` wraps `scripts/validate.py` to check both manifests, dual-host parity, declared component paths, kebab-case names, hook-config JSON, skill/command/agent frontmatter (**agents must use `tools:` not `allowed-tools:`** — flagged as an error), hook parity (the same `hooks/*.sh` wired for the equivalent event on both hosts, each one existing and executable, none left unwired), doc component inventories (every shipped skill, agent and hook named in `README.md`, this file, and `docs/testing.md`; hooks alone in `docs/install.md`), and two *advice == tooling* invariants: every linter taught in a component is enabled in `references/golangci.v2.yml`, and (when a floor-minor Go toolchain is on PATH — CI's matrix installs `1.26.x` and `1.27.x`; the strict check runs on the 1.26.x (floor) leg, the 1.27.x leg soft-skips it) the `go-idioms` Fixer column matches `go tool fix help`. The Python is stdlib-only. `.github/workflows/validate.yml` pins Python + Go and runs the validator strictly. - **Contributor docs**: `docs/` for human-facing references — `install.md`, `testing.md`, `versioning.md`, `authoring.md`. `.github/` holds issue + PR templates, `copilot-instructions.md`, and the CI workflow. (Planning and research working notes are kept locally under `docs/`, **gitignored** — not part of the published plugin.) ### Component surface -The full component surface — all shipped: +The full component surface: -- **Skills** — *shipped*: `go-coding` (auto-invoked router) + `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-linting`, `go-layout`. Each routes deeper topics to the enforcing tool and cites authoritative sources. -- **Slash commands** — *shipped* as user-invoked skills: `/go-explain` (idiom/standard lookup) and `/go-lint-setup` (scaffold the golangci-lint v2 config). -- **Agent** — *shipped*: `go-reviewer`, a context-isolated, read-only reviewer applying the review-heuristics catalog (no sub-agent dispatch; treats the diff as untrusted content). +- **Skills** — *shipped*: `go-coding` (auto-invoked router) + `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-layout`. Each routes deeper topics to the enforcing tool and cites authoritative sources. +- **Slash commands** — *shipped* as user-invoked skill: `/go-lint-setup` (scaffold the golangci-lint v2 config). +- **Removed in 0.5.0** — `go-linting` (merged into `go-lint-setup`) and `/go-explain` (a one-shot lookup the focused skills already answer). Do not re-add either; route the topic instead. +- **Agent** — *shipped*: `go-reviewer`, a context-isolated, report-only reviewer applying the review-heuristics catalog (no sub-agent dispatch; treats the diff as untrusted content). - **Cursor rule** — *shipped*: `rules/go-context.mdc`, scoped to `**/*.go`, mirroring the `go-coding` router for Cursor. ## Development @@ -59,6 +60,7 @@ No build step — the plugin is pure Markdown + JSON. Validate and dogfood local ```bash ./scripts/validate.sh # dual-host parity / frontmatter (soft-skips if no python3) +./scripts/hooks-test.sh # bash tests for hooks/session-start.sh + hooks/skill-nudge.sh claude plugin validate . # manifest + component structure (no extra deps) claude --plugin-dir /path/to/go-coding-plugin # load locally for one session (dogfooding) ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index e77b3ca..87148d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,72 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - Keep a Changelog: https://keepachangelog.com/en/1.1.0/ - Semantic Versioning: https://semver.org/spec/v2.0.0.html +## [0.5.0] - 2026-09-03 + +Makes the `go-coding` router route. A usage analysis of local session transcripts found the router +loading often and the focused skills almost never, so this release adds a hop table, a post-edit +nudge, trigger-first descriptions and a banner that names the next step. It also shrinks the +surface — `go-linting` merged into `go-lint-setup`, `/go-explain` removed — adds Go 1.27 support on +the existing Go 1.26.4+ floor, and ships a script for measuring adoption. + +### Added +- Skill `go-coding` — a "Route, then load" table mapping diff content to the skill to load, and a + minimum checklist for when a second load is not affordable. +- Skill `go-coding`, agent `go-reviewer` — "Writing for the human": anything a person reads (PR + text, review findings, a question) states the effect before the mechanism, in plain English, short. +- Hook `hooks/skill-nudge.sh` — after a Go edit, names one skill the edit calls for. Under Claude + Code it matches only the text the edit adds (`tool_input.new_string`, or `content` for a write), + not the file and not the deleted text; Cursor passes a path alone, so there it matches the file + and says so. Once per skill per session, at most three per session; a `systemMessage` under + Claude Code, a plain line under Cursor. +- Script `scripts/hooks-test.sh` — 28 bash tests over all three hooks, on production-shaped + payloads, asserting exit status as well as output, with can-fail controls proving the silence + assertions can actually fail. Runs in CI. +- Script `scripts/usage-report.py` — stdlib-only adoption report from local session transcripts; + counting rules and the 50% target in `docs/testing.md`. +- Docs `README.md` — implementer and reviewer brief templates for orchestrators, since a subagent + does not inherit the parent session's skills. +- Skills `go-idioms`, `go-lint-setup` — Go 1.27 hints (generic methods, json/v2-backed + `encoding/json`, the four new `go fix` modernizers, `stdversion` by default under `go test`) and + the golangci-lint ≥ v2.13.0 floor for Go 1.27. +- CI `.github/workflows/validate.yml` — Go matrix `1.26.x` + `1.27.x`, `fail-fast: false`; the + floor leg keeps the strict `go-idioms` Fixer-column check. +- Validation `scripts/validate.py` — hook parity (the same script wired for the equivalent event on + both hosts, existing and executable, none left unwired) and doc component inventories (every + shipped skill, agent and hook named where the docs claim to list them). +- Validation `scripts/validate.py --selftest` — rebuilds each structural check's failure case in a + temporary tree and requires the check to catch it, and to stay quiet once the defect is removed. + Runs in CI, because a green run over a valid tree says nothing about whether a check still checks. + +### Changed +- Skills — descriptions rewritten trigger-first and shortened 10% (4,619 → 4,163 characters). + Always-on context competes with the session's real work. `go-idioms` in particular no longer + triggers on "writes or reviews Go", which was nearly every Go turn. +- Skill `go-lint-setup` — absorbs `go-linting`'s config-schema, adoption and upgrade-breakage + content; re-fronted as "scaffold, adopt, or debug". +- Hook `hooks/session-start.sh` — the banner names the hop and the focused skills, and offers + `/go-lint-setup` only when the workspace has no golangci-lint config. +- Agent `go-reviewer`, skill `go-coding` — one review seat per diff: where a workflow already has a + reviewer, that reviewer loads the skills itself instead of `go-reviewer` being dispatched beside it. +- Cursor rule `rules/go-context.mdc` — brought level with the router: the diff→skill mapping, the + minimum checklist, and the plain-English rule for anything a person reads. Drops the Claude-only + `${CLAUDE_PLUGIN_ROOT}` reference from a Cursor-only file. +- Skills `go-idioms`, `go-testing` — four Go 1.27 claims corrected against their sources: v1 + `encoding/json` stays the default (the release notes say users are not required to migrate); + `embedlit` folds a promoted field into the parent literal rather than a post-literal assignment; + `unsafefuncs` gains the table row its prose already assumed; `stdversion` is dated to when `go + test` starts running it by default, not to the check's existence. `httptest.NewTestServer` now + carries its signature. +- Docs `docs/install.md` — `gopls` pin moves to v0.23.x, the line that adds Go 1.27 support. +- Docs `docs/testing.md` — adds the missing `format-on-save` triggering check. +- Skills, agent, Cursor rule, docs — Go 1.26.4+ remains the hard floor; Go 1.27 is supported and its + additions are flagged as hints. `docs/install.md` recommends the latest 1.27.x patch. + +### Removed +- Skill `go-linting` — merged into `go-lint-setup`, the skill the router and banner point at. +- Skill `/go-explain` — one-shot lookups are what the focused skills already do, with the same + citations and the code in view. + ## [0.4.1] - 2026-08-25 Corrects three component defects and the docs that described them: a `PostToolUse` hook timeout that was five hours rather than twenty seconds, a reviewer agent calling itself read-only while holding `Bash`, and `go-lint-setup` offering a migration it had no tool grant to run. diff --git a/README.md b/README.md index 5ee4e75..93abf40 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Go Coding Plugin -An AI plugin by **Cadasto B.V.** that teaches AI coding assistants **idiomatic Go coding standards** — formatting, naming, error handling, concurrency, testing, and project layout — through skills, an agent, session-start and format-on-save hooks, and a Cursor rule. It targets **both Claude Code and Cursor** from a single shared component set. +An AI plugin by **Cadasto B.V.** that teaches AI coding assistants **idiomatic Go coding standards** — formatting, naming, error handling, concurrency, testing, and project layout — through skills, an agent, three hooks (session-start, format-on-save, skill-nudge), and a Cursor rule. It targets **both Claude Code and Cursor** from a single shared component set. ## Install @@ -15,7 +15,7 @@ Or load a local working copy for a single session: `claude --plugin-dir /path/to **Cursor**: add this repository as a plugin (Settings → Plugins). See [`docs/install.md`](docs/install.md) for both hosts. -**Prerequisites** — the plugin installs without a Go toolchain, but its hooks and enforcement guidance expect **Go 1.26.4+** plus `gofmt`, `gofumpt`, `goimports`, and `gopls` on the host `PATH`. See [Host toolchain (minimal requirements)](docs/install.md#host-toolchain-minimal-requirements) for what each tool drives and copy-paste install commands. +**Prerequisites** — the plugin installs without a Go toolchain, but its hooks and enforcement guidance expect **Go 1.26.4+** (Go 1.27 supported; its additions are flagged as hints) plus `gofmt`, `gofumpt`, `goimports`, and `gopls` on the host `PATH`. See [Host toolchain (minimal requirements)](docs/install.md#host-toolchain-minimal-requirements) for what each tool drives and copy-paste install commands. ## Component surface @@ -24,20 +24,37 @@ Or load a local working copy for a single session: `claude --plugin-dir /path/to | Skill `go-coding` | shipped | Auto-invoked router: sends each Go topic to the enforcing tool and the focused skill below; recommends `gopls-lsp`. | | Session-start hook | shipped | Detects a Go workspace (`go.mod`/`*.go`) and prints one standards line; dual-host. | | Format-on-save hook | shipped | After each `Write`/`Edit` of a `*.go` file, runs `gofumpt -w` (or `gofmt -w -s`) on it; dual-host, host-only, silent no-op if no formatter is installed. | -| Skills `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-linting`, `go-layout` | shipped | Load-on-use standards — each rule cited, framed around the enforcing linter (`modernize`, `errorlint`, `-race`, …). `go-layout` also owns naming, doc comments, and exported-API shape. | +| Skill-nudge hook | shipped | After each `Write`/`Edit` of a `*.go` file, names ONE matching go-coding skill for that edit, once per skill per session; dual-host — delivered as a hook `systemMessage` under Claude Code, a plain line under Cursor. | +| Skills `go-errors`, `go-concurrency`, `go-testing`, `go-idioms`, `go-layout` | shipped | Load-on-use standards — each rule cited, framed around the enforcing linter (`modernize`, `errorlint`, `-race`, …). `go-layout` also owns naming, doc comments, and exported-API shape. | | Agent `go-reviewer` | shipped | Report-only, context-isolated Go reviewer for what linters miss; severity-ranked findings, no sub-agent dispatch. Its grant excludes `Write`/`Edit` but includes `Bash` to run the linters, so no-edit is a contract it keeps rather than a sandbox that enforces it. | -| Skills `/go-explain`, `/go-lint-setup` (user-invoked) | shipped | Slash-command skills — idiom/standard lookup; scaffold the golangci-lint v2 config into a repo. | +| Skill `/go-lint-setup` (user-invoked) | shipped | Slash-command skill — scaffold, adopt, or debug the golangci-lint v2 config in a repo. | | Lint config `references/golangci.v2.yml` | shipped | Reference golangci-lint v2 config (`modernize` + stack linters). | | Cursor rule `go-context.mdc` | shipped | `**/*.go`-scoped guidance mirroring the router for Cursor. | +| Scripts `scripts/hooks-test.sh`, `scripts/usage-report.py` | shipped | Dev tooling, not part of the installed component surface: a bash test harness for the hooks, and a stdlib-only adoption-report generator over local session transcripts. | Guidance is grounded in authoritative sources — [Effective Go](https://go.dev/doc/effective_go), [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments), the [Google](https://google.github.io/styleguide/go/) and [Uber](https://github.com/uber-go/guide) style guides — and the standard toolchain (`gofmt`/`gofumpt`, `go vet`, `staticcheck`, `golangci-lint`, `go test -race`). +## Using with subagent orchestrators + +Subagents do not inherit the parent session's skills. A plan runner that dispatches implementers +and reviewers must say so in every brief: + +- **Implementer brief:** "Before writing code, invoke the Skill tool with `go-coding:go-coding`, then + the focused skills matching your diff (see its *Route, then load* table). Run `golangci-lint run` + on every touched package before committing." +- **Reviewer brief:** "Before reading the diff, load `go-coding:go-coding` plus `go-errors`, + `go-testing` and the skills the diff calls for; cite the rule a finding rests on. Do not dispatch + `go-reviewer` — you are the review seat." + +Use `go-reviewer` directly when no such seat exists (an ad-hoc "review this file" request). + ## Development No build step — the plugin is pure Markdown + JSON. Validate locally: ```bash -./scripts/validate.sh # manifests, parity, paths, frontmatter, taught linters, fixers +./scripts/validate.sh # manifests, parity, paths, frontmatter, hooks, doc inventories, linters, fixers +./scripts/hooks-test.sh # bash tests for hooks/session-start.sh + hooks/skill-nudge.sh claude plugin validate . # manifest + component structure ``` diff --git a/agents/go-reviewer.md b/agents/go-reviewer.md index d889095..df781da 100644 --- a/agents/go-reviewer.md +++ b/agents/go-reviewer.md @@ -9,7 +9,10 @@ description: > refactor ("review the worker pool in scheduler.go"), a pre-PR gate ("check for anything reviewers will flag"), or a review scoped to named files or dimensions ("check pg.go for resource leaks and context handling"). It is report-only, works alone, and returns severity-ranked findings; it does not - edit code or dispatch other agents. Not for non-Go languages or for problems + edit code or dispatch other agents. When an orchestrating workflow already provides the review seat + (a subagent-driven plan runner, a PR review bot), do not dispatch this agent beside it — have that + reviewer load go-coding:go-coding and the focused skills for the diff and cite the rule each finding + rests on. Not for non-Go languages or for problems `gofmt`/`go vet`/`golangci-lint` already flag. See "When to invoke" in the agent body for worked scenarios. model: inherit @@ -21,7 +24,7 @@ tools: - Bash --- -You are **go-reviewer**, a reviewer of idiomatic, correct Go (Go 1.26.4+; golangci-lint v2). You supply +You are **go-reviewer**, a reviewer of idiomatic, correct Go (Go 1.26.4+, Go 1.27 supported with its additions flagged as hints; golangci-lint v2). You supply the judgment a linter cannot — the bugs and smells that survive `gofmt`, `go vet`, and `golangci-lint`. You are **report-only**: you report findings, you never edit code. Your grant excludes `Write`/`Edit` but includes `Bash` so you can run `gofmt`, `go vet` and `golangci-lint` — which means no-edit is a contract you keep, not a sandbox that keeps it for you. Never invoke a formatter's `-w`, `--fix`, or any in-place flag. @@ -114,7 +117,7 @@ the judgment a linter cannot — the bugs and smells that survive `gofmt`, `go v allocating before a level check; key-value variadic on a hot path instead of `slog.LogAttrs`. For the *why* and citations behind any dimension, the `go-errors`, `go-concurrency`, `go-testing`, -`go-idioms`, `go-linting`, and `go-layout` skills carry the grounded rules — reference them rather +`go-idioms`, `go-lint-setup`, and `go-layout` skills carry the grounded rules — reference them rather than re-deriving from memory. ## Output format @@ -137,6 +140,10 @@ Verdict: 3 issues — 1 high, 2 medium. If you find nothing real, say so plainly — **do not invent findings to look thorough.** End with a one-line note of what you did *not* cover (files or paths outside the given scope). +A person reads this report, so write the prose in plain English: say what goes wrong before naming +the mechanism, and expand a Go term the first time it appears or leave it out. Identifiers, +commands and linter names stay verbatim. Keep each finding to the three lines above. + ## Edge cases - **No diff given and none inferable:** ask for the diff/files, or run `git diff` if a branch is in diff --git a/docs/authoring.md b/docs/authoring.md index 28f58c1..0cff9f8 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -20,7 +20,7 @@ The detailed companion to [AGENTS.md](../AGENTS.md) (which is authoritative); th standards skills are the model. - **Skill (user-invoked / slash command)** — a thin one-shot `skills//SKILL.md` that also carries `argument-hint` + `allowed-tools`; use `$ARGUMENTS` in the body. Invoked as `/`. See - `/go-explain`, `/go-lint-setup`. (The legacy `commands/` folder is not used.) + `/go-lint-setup`. (The legacy `commands/` folder is not used.) - **Agent** — a context-isolated specialist. Use **`tools:`** (a YAML block list), **never** `allowed-tools:` — in an agent that key is silently ignored and the agent inherits *all* tools. See `go-reviewer` (report-only, no sub-agent dispatch). @@ -88,7 +88,8 @@ citation. Everything the skills assert should be traceable to one of these. 1. Confirm the current *released* Go version (release history) — a draft `go1.NN` page is not a baseline. Guidance for an unreleased version goes in as one *italic, explicitly labelled* sentence (`*Go 1.NN (draft, expected …)*`), never as a rule. - **The baseline is a hard floor** (currently **Go 1.26.4+**): recommend the modern form flat, with + **The baseline is a hard floor** (currently **Go 1.26.4+**; Go 1.27 is supported too, with its + additions flagged as hints rather than folded into the floor): recommend the modern form flat, with no "on 1.NN+ modules prefer…" hedging and no fallback branch for older toolchains. Keep the version annotation (`Since`, "(Go 1.24)") — that is provenance, and it tells a reader on an older module what a bump would buy. When the floor moves, delete the guidance below it. @@ -102,17 +103,17 @@ citation. Everything the skills assert should be traceable to one of these. x/tools tip, which is usually ahead of what `go fix` ships; same idea for linters (`golangci-lint help linters` on the pinned build). `scripts/validate.py` enforces both halves: every linter taught in components must be enabled in `references/golangci.v2.yml`, - and — when a floor-minor Go toolchain is on PATH (CI installs `1.26.x`; locally it - soft-skips with a note) — the `go-idioms` Fixer column is verified against + and — when a floor-minor Go toolchain is on PATH (CI's matrix installs both `1.26.x` and + `1.27.x`; locally it soft-skips with a note) — the `go-idioms` Fixer column is verified against `go tool fix help`: plain names must be registered, † names must not be. The floor minor - lives in `GO_FLOOR_MINOR` in the script and in the workflow's `setup-go` pin — move all - three (docs baseline included) together. + lives in `GO_FLOOR_MINOR` in the script and in the workflow's matrix floor entry (`1.26.x`) — move + all three (docs baseline included) together. **Never hardcode a tool version in a component.** A named `golangci-lint` release rots within weeks and nobody remembers why it was chosen; the skills carry the *pin policy* (pin exactly, one source of truth, automated bump PR) plus the changelog URL, and let the consuming repo own the number. The same goes for `gopls`/`gofumpt` versions outside `docs/install.md`. -5. Keep the three copies of the reference lint config in sync: `references/golangci.v2.yml`, the - block in `go-linting`, and the block in `go-lint-setup`. +5. Keep the two copies of the reference lint config in sync: `references/golangci.v2.yml` and the + scaffold block in `go-lint-setup`. 6. Record the refresh in **CHANGELOG.md** under `## [Unreleased]`. ## Dual-host parity diff --git a/docs/install.md b/docs/install.md index 8b0925a..8a4fb57 100644 --- a/docs/install.md +++ b/docs/install.md @@ -51,54 +51,55 @@ At minimum the host should provide: | Tool | Provided by | Used for | If missing | |------|-------------|----------|------------| -| **Go 1.26.x** (min 1.26.4) | [go.dev/dl](https://go.dev/dl/) / package manager | everything; satisfies `go.mod` `go 1.26.x`; `go fix ./...` runs the modernizers | no toolchain at all | +| **Go 1.26.4+ (1.27.x recommended)** | [go.dev/dl](https://go.dev/dl/) / package manager | everything; satisfies a `go.mod` `go 1.26.x` or `1.27.x` directive; `go fix ./...` runs the modernizers | no toolchain at all | | **`gofmt`** | the Go distribution | `format-on-save.sh` fallback (`gofmt -w -s`) | n/a — always ships with Go | | **`gofumpt`** | `go install` | `format-on-save.sh` primary (`gofumpt -w`), stricter gofmt superset | hook degrades to `gofmt` | | **`goimports`** | `go install` | `goimports` formatter in the golangci-lint v2 config (import grouping/pruning) | import-group formatting skipped | -| **`gopls`** (v0.22.x) | `go install` | the `gopls-lsp` plugin (defs/refs/diagnostics/rename/vulncheck) | no code intelligence | +| **`gopls`** (v0.23.x) | `go install` | the `gopls-lsp` plugin (defs/refs/diagnostics/rename/vulncheck) | no code intelligence | ### Install / upgrade Go (official tarball, Linux) -Pick the latest **1.26.x** patch (1.26.4 or newer) from and the build matching your platform (`linux-amd64` shown): +Pick the latest **1.27.x** patch (**go1.27.1** at time of writing) from and the build matching your platform (`linux-amd64` shown) — the plugin's floor is **Go 1.26.4 or newer**, so an existing 1.26.4+ toolchain also works and nothing below requires the upgrade: ```bash -# replace the version with the current latest 1.26.x patch (1.26.4+) -curl -fLO https://go.dev/dl/go1.26.4.linux-amd64.tar.gz +# replace the version with the current latest 1.27.x patch (go1.27.1 at time of writing; 1.26.4+ also works) +curl -fLO https://go.dev/dl/go1.27.1.linux-amd64.tar.gz sudo rm -rf /usr/local/go # remove any prior install (don't overlay) -sudo tar -C /usr/local -xzf go1.26.4.linux-amd64.tar.gz +sudo tar -C /usr/local -xzf go1.27.1.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin # add to your shell profile if not already present -go version # → go version go1.26.4 linux/amd64 +go version # → go version go1.27.1 linux/amd64 ``` -> macOS/Windows or a package manager (Homebrew `go`, `winget`, distro packages) work equally well — the only requirement is that `go version` reports **1.26.x** (1.26.4+). `gofmt` is included in every Go distribution, so nothing extra is needed for the hook's fallback path. +> macOS/Windows or a package manager (Homebrew `go`, `winget`, distro packages) work equally well — the only requirement is that `go version` reports **1.26.4 or newer** (1.27.x recommended). `gofmt` is included in every Go distribution, so nothing extra is needed for the hook's fallback path. ### Install the supporting tools -`go install` drops binaries in `$(go env GOPATH)/bin` (default `~/go/bin`) — make sure that directory is on your `PATH`. Run these **after** Go is in place so they compile against your 1.26 toolchain: +`go install` drops binaries in `$(go env GOPATH)/bin` (default `~/go/bin`) — make sure that directory is on your `PATH`. Run these **after** Go is in place so they compile against your installed toolchain (1.26.4+ or 1.27.x): ```bash go install mvdan.cc/gofumpt@latest # stricter gofmt superset (hook primary) go install golang.org/x/tools/cmd/goimports@latest # import grouping / pruning -go install golang.org/x/tools/gopls@v0.22.0 # language server for the gopls-lsp plugin (pinned: v0.22.x — the gopls line that adds Go 1.26 support; use @latest for the newest patch) +go install golang.org/x/tools/gopls@v0.23.0 # language server for the gopls-lsp plugin (pinned: v0.23.x — the gopls line that adds Go 1.27 support; use @latest for the newest patch) ``` Verify: ```bash -go version # → 1.26.x (1.26.4+) +go version # → 1.27.x (or 1.26.4+ on the floor) command -v gofmt # ships with Go (in GOROOT/bin) gofumpt --version command -v goimports # goimports has no --version flag -gopls version # → golang.org/x/tools/gopls v0.22.x +gopls version # → golang.org/x/tools/gopls v0.23.x ``` -These are **host-only** dev tools; the plugin still works without them (the format hook degrades to `gofmt`, then to a silent no-op). Full-tree `golangci-lint` runs separately — often in a pinned container — so it does not depend on these host binaries. +These are **host-only** dev tools; the plugin still works without them (the format hook degrades to `gofmt`, then to a silent no-op). Full-tree `golangci-lint` runs separately — often in a pinned container — so it does not depend on these host binaries. On Go 1.27, that container/pin needs **golangci-lint ≥ v2.13.0** (released 2026-08-19, the release that added Go 1.27 support) — see ; anything older predates 1.27 support. ## Hooks -The plugin ships two host-agnostic hooks (Claude `hooks/hooks.json`, Cursor `hooks/cursor-hooks.json`): +The plugin ships three host-agnostic hooks (Claude `hooks/hooks.json`, Cursor `hooks/cursor-hooks.json`): - **`session-start.sh`** — on session start, detects a Go workspace (`go.mod`/`*.go`) and prints one standards line. - **`format-on-save.sh`** — after each edit of a `*.go` file (Claude `PostToolUse` on `Write`/`Edit`; Cursor `afterFileEdit`), runs **`gofumpt -w`** on that file, or **`gofmt -w -s`** when `gofumpt` is not installed. It is **host-only** (no container round-trip), **edits the file in place**, and is a **silent no-op** when no Go formatter is on `PATH` — so install `gofmt` (ships with Go) or `gofumpt` to benefit. It never blocks an edit. This is per-file formatting only; run `golangci-lint` and your tests via CI/`make` for full-tree checks. +- **`skill-nudge.sh`** — after each edit of a `*.go` file (same trigger as `format-on-save.sh`), names ONE matching go-coding skill for that edit (a `_test.go` file → `go-testing`; goroutine/channel/`sync`/`atomic`/`errgroup` content → `go-concurrency`; `fmt.Errorf`/`errors.*` → `go-errors`), once per skill per session. Delivered as a hook `systemMessage` under Claude Code (reaches the model's context on exit 0) or a plain line under Cursor. It never blocks an edit. > The Cursor wiring targets the `afterFileEdit` event; if your Cursor version exposes a different post-edit event or payload shape, adjust `hooks/cursor-hooks.json` and the path-extraction in `format-on-save.sh` accordingly. diff --git a/docs/testing.md b/docs/testing.md index b19eac1..1c61483 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,7 +6,9 @@ exercising the components. ## Validation -- **Manifest / component validation** — `./scripts/validate.sh` (also run by CI on every PR): checks both `plugin.json` manifests, dual-host parity (name/version/description/author agree), declared component paths, kebab-case names, hook-config JSON, and SKILL.md / agent / command frontmatter (including `name` == directory/filename, and that agents declare `tools:` not `allowed-tools:`). The wrapper runs `scripts/validate.py`; if Python 3 isn't installed it prints a warning and skips (exit 0) rather than failing — install `python3` for the full local check, or rely on `claude plugin validate .` and CI. CI pins Python so the deep check always runs there. +- **Manifest / component validation** — `./scripts/validate.sh`: checks both `plugin.json` manifests, dual-host parity (name/version/description/author agree), declared component paths, kebab-case names, hook-config JSON, hook parity (the same `hooks/*.sh` wired for the equivalent event on both hosts, each existing and executable, none left unwired), doc component inventories (every shipped skill, agent and hook named where the docs claim to list them), and SKILL.md / agent / command frontmatter (including `name` == directory/filename, and that agents declare `tools:` not `allowed-tools:`). The wrapper runs `scripts/validate.py`; if Python 3 isn't installed it prints a warning and skips (exit 0) rather than failing — install `python3` for the full local check, or rely on `claude plugin validate .` and CI. CI pins Python and calls `python3 scripts/validate.py` directly, so the deep check can never silently skip there. +- **Validator self-test** — `python3 scripts/validate.py --selftest` (also run by CI): rebuilds each structural check's failure case in a temporary tree and requires the check to catch it, so a check that has quietly stopped checking cannot pass as green. +- **Hook tests** — `./scripts/hooks-test.sh` (also run by CI on every PR): bash tests for all three hook scripts (`hooks/session-start.sh`, `hooks/format-on-save.sh`, `hooks/skill-nudge.sh`), including a can-fail self-test block that proves the negative-case helpers actually fail on bad input. - **Official validator** — `claude plugin validate .`: checks the manifest and component structure (no extra dependencies). - **Structural review** — run the `plugin-dev:plugin-validator` agent after creating or modifying components. - **Skill quality review** — run the `plugin-dev:skill-reviewer` agent: description-triggering quality, progressive disclosure, content structure. @@ -18,9 +20,46 @@ Install from your working copy (see [install.md](install.md)), then exercise eac - **Session-start hook** — open a repo with a `go.mod`/`*.go`; one Go-standards line should print at session start (and nothing in a non-Go repo). - **`go-coding` router** — ask for a Go review or idiom help; it should route to the enforcing tool and the focused skill. -- **Standards skills** — a topic prompt should engage the matching skill (for example error wrapping → `go-errors`, a flaky time-based test → `go-testing`/`go-concurrency`, linter setup → `go-linting`). +- **Standards skills** — a topic prompt should engage the matching skill (for example error wrapping → `go-errors`, a flaky time-based test → `go-testing`/`go-concurrency`, linter setup → `go-lint-setup`). +- **Format-on-save hook** — save a deliberately mis-formatted `*.go` file; `format-on-save.sh` should reformat that one file in place (`gofumpt -w`, or `gofmt -w -s` when `gofumpt` is absent) and say nothing when neither is installed. +- **Skill-nudge hook** — edit a `_test.go` file; the nudge should name `go-coding:go-testing` (as a systemMessage under Claude Code, a plain line under Cursor) and, critically, the model should ACT on it — load the skill — not merely have the line appear in the transcript. A second edit to a `_test.go` file in the same session should be silent (once per skill per session), and so should an edit that does not itself touch the topic — a doc-comment fix in a file that defines a sentinel elsewhere must not claim the edit touches an error path. - **`go-reviewer` agent** — ask for a Go code review; it returns severity-ranked findings and does not spawn sub-agents. -- **Slash commands (skills)** — `/go-explain ` and `/go-lint-setup`. +- **Slash command (skill)** — `/go-lint-setup`. - **Cursor rule** — in Cursor, open a `.go` file and confirm `go-context.mdc` attaches. After editing content, reinstall (or restart the session) to pick up changes. + +## Measuring adoption + +The layout and concurrency skills, and the router's dispatch behavior, were shaped by +a usage analysis of local Claude Code session transcripts. `scripts/usage-report.py` +(stdlib-only) reproduces that measurement so adoption stays checkable over time: + +``` +python3 scripts/usage-report.py --since YYYY-MM-DD --out report.md +``` + +Run with no arguments to scan `~/.claude/projects` from the beginning and print the +report to stdout; `--since` narrows to a start date, `--out` writes the report to a +file instead. `--help` repeats the counting rules. + +**Counted:** a `Skill` tool invocation whose `skill` input starts with `go-coding:`; a +`Task`/`Agent` tool invocation with `subagent_type` `go-coding:go-reviewer`; a user +`` invocation naming a go-coding skill. Events are split into main-session, +subagent, and user-invoked, per month. The report renders two tables: "Per skill / agent" +counts every event (so a skill loaded three times in one session counts three times), and +"Sessions with >=1 event, per skill / agent" counts distinct sessions instead — a skill +loaded three times in one session counts once there. The 50% target below is read off the +sessions table: (sessions loading a given focused skill) / (sessions loading +`go-coding:go-coding`). + +**Not counted:** the SessionStart banner line, or skill-body text echoed back inside +tool results — only structured tool invocations and explicit slash-command text count. +Nor is Cursor: the script reads Claude Code transcripts (`~/.claude/projects`) only, so +the numbers describe adoption on one of the two hosts. + +**Target:** the focused standards skills (`go-errors`, `go-testing`, `go-idioms`, +`go-lint-setup`, `go-layout`, `go-concurrency`) should load on at least 50% of sessions +where the `go-coding` router itself loads, and `go-layout` / `go-concurrency` +specifically should show non-zero counts in any period where the corresponding work +(project layout/API design, or goroutines/channels/context) is actually touched. diff --git a/docs/versioning.md b/docs/versioning.md index 4c754a7..1d5d524 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -17,7 +17,7 @@ bump. 1. Bump `version` in **both** manifests (they must agree): `.claude-plugin/plugin.json` and `.cursor-plugin/plugin.json`. Keep `description` and `author` identical across both — `scripts/validate.py` enforces this parity. -2. Run `./scripts/validate.sh` and `claude plugin validate .`. +2. Run `./scripts/validate.sh`, `./scripts/hooks-test.sh`, and `claude plugin validate .`. 3. **Dogfood:** load the working copy (`claude --plugin-dir /path/to/go-coding-plugin`) and exercise the components against a real Go change on **both** hosts — see [testing.md](testing.md). 4. Fold the accumulated `## [Unreleased]` notes into a dated `## [X.Y.Z] - YYYY-MM-DD` section in diff --git a/hooks/cursor-hooks.json b/hooks/cursor-hooks.json index ccde61e..ba804bf 100644 --- a/hooks/cursor-hooks.json +++ b/hooks/cursor-hooks.json @@ -8,6 +8,9 @@ "afterFileEdit": [ { "command": "bash hooks/format-on-save.sh" + }, + { + "command": "bash hooks/skill-nudge.sh" } ] } diff --git a/hooks/hooks.json b/hooks/hooks.json index 42f88e9..e0e0ff2 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -18,6 +18,11 @@ "type": "command", "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/format-on-save.sh", "timeout": 20 + }, + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/skill-nudge.sh", + "timeout": 5 } ] } diff --git a/hooks/session-start.sh b/hooks/session-start.sh index d7cd68b..0efe5a6 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -14,7 +14,11 @@ is_go_workspace() { } if is_go_workspace; then - echo "› Go workspace detected — go-coding standards available (ask for a Go review or idiom guidance; gofmt + golangci-lint v2 and the gopls-lsp plugin recommended)." + lint=" · /go-lint-setup scaffolds golangci-lint v2 (no config found)" + for cfg in .golangci.yml .golangci.yaml .golangci.toml .golangci.json; do + [ -e "$cfg" ] && lint="" && break + done + echo "› Go workspace — go-coding: load go-coding then the skill for your diff (go-errors · go-testing · go-idioms · go-concurrency · go-layout); go-reviewer for a diff review${lint}. gofmt/golangci-lint v2 + gopls-lsp recommended." fi exit 0 diff --git a/hooks/skill-nudge.sh b/hooks/skill-nudge.sh new file mode 100755 index 0000000..7c01112 --- /dev/null +++ b/hooks/skill-nudge.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# PostToolUse / afterFileEdit hook: after a Go file is edited, name ONE go-coding skill the edit +# calls for — printed as a hook systemMessage (Claude Code) or a plain line (Cursor). Deterministic +# trigger for three focused skills a usage analysis showed load far less often than the router: +# go-testing, go-concurrency, go-errors. Always exits 0; never blocks an edit. +# +# A test file always routes to go-testing, even when it also spawns goroutines — the test skill +# owns how to test concurrency. At most three nudges reach a session, one per skill. +set -u +f="${CLAUDE_FILE_PATH:-}"; sid=""; payload="" +if [ ! -t 0 ]; then + payload="$(cat)" + [ -n "$f" ] || f="$(printf '%s' "$payload" | grep -oE '"file_?[Pp]ath"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 | sed -E 's/.*"([^"]+)"$/\1/')" + sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 | sed -E 's/.*"([^"]+)"$/\1/')" +fi +[ -n "$f" ] || exit 0 +case "$f" in *.go) ;; *) exit 0 ;; esac +[ -f "$f" ] || exit 0 +[ -n "$sid" ] || sid="ppid$PPID" + +# Pull one JSON string field out of the payload. The body pattern `(\\.|[^"\\])*` matches an +# escaped character or an ordinary one, so an embedded \" does not end the match early. +json_field() { printf '%s' "$payload" | grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"(\\\\.|[^\"\\\\])*\""; } + +# What gets matched: the text the edit ADDS, where the host hands it over — Edit's new_string, +# Write's content. old_string and tool_response are deliberately excluded: deleting an fmt.Errorf +# is not an error path this edit introduces, and tool_response echoes surrounding lines the edit +# never touched. Cursor's afterFileEdit passes only a path, so that host matches the whole file +# and the message says so. No fallback from one to the other — falling back to the file on an +# empty extraction would restore exactly the false positives this avoids. +case "$payload" in + *'"tool_input"'*) subject="$(json_field new_string; json_field content)"; what="this edit";; + *) subject="$(cat "$f")"; what="this file";; +esac + +skill=""; topic="" +case "$f" in *_test.go) skill="go-testing"; topic="a test file";; esac +if [ -z "$skill" ] && printf '%s' "$subject" | grep -qE 'go func|chan |<-chan|chan<-|sync\.|atomic\.|errgroup\.'; then skill="go-concurrency"; topic="goroutines, channels or sync"; fi +if [ -z "$skill" ] && printf '%s' "$subject" | grep -qE 'fmt\.Errorf|errors\.(Is|As|AsType|New|Join)'; then skill="go-errors"; topic="an error path"; fi +[ -n "$skill" ] || exit 0 +marker="${TMPDIR:-/tmp}/go-coding-nudge.${sid}.${skill}" +[ -e "$marker" ] && exit 0 +: > "$marker" 2>/dev/null || true +# what/topic/skill above are fixed literals set in this script (no quotes or backslashes), so the +# message below needs no JSON escaping before it goes into printf. If any is ever built from +# variable/external text instead, escape it first — printf does no JSON escaping of its own. +msg="› go-coding: ${what} touches ${topic} — load go-coding:${skill} before continuing." +if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || printf '%s' "$payload" | grep -q '"hook_event_name"'; then + printf '{"systemMessage":"%s"}\n' "$msg" # Claude Code: systemMessage reaches the model's context on exit 0 +else + printf '%s\n' "$msg" # Cursor afterFileEdit: plain line +fi +exit 0 diff --git a/references/golangci.v2.yml b/references/golangci.v2.yml index 015cf44..1d79f6d 100644 --- a/references/golangci.v2.yml +++ b/references/golangci.v2.yml @@ -4,9 +4,9 @@ # Schema is golangci-lint v2 — a v1 config will NOT parse (run `golangci-lint migrate` on a v1 # config). Needs a golangci-lint v2 release recent enough to know every linter named below; if it # rejects one, bump the pin rather than dropping the line. Pin an exact version in CI in one place -# (see `go-linting` — no version is blessed here on purpose). See the `go-linting` skill for what -# each linter does and why. Keep this in sync with BOTH inlined copies: the reference block in -# `skills/go-linting/SKILL.md` and the scaffold block in `skills/go-lint-setup/SKILL.md`. +# (see `go-lint-setup` — no version is blessed here on purpose). For what each linter does and why, +# see the inline comments below; for adopting or debugging an existing config, see the +# `go-lint-setup` skill. Keep this in sync with the scaffold block in `skills/go-lint-setup/SKILL.md`. version: "2" linters: diff --git a/rules/go-context.mdc b/rules/go-context.mdc index 3fa9428..32dbbde 100644 --- a/rules/go-context.mdc +++ b/rules/go-context.mdc @@ -1,5 +1,5 @@ --- -description: Go coding standards — idiomatic Go (Go 1.26.4+; golangci-lint v2) for .go files. Mirrors the go-coding router skill for Cursor. +description: Go coding standards — idiomatic Go (Go 1.26.4+, Go 1.27 supported with hints; golangci-lint v2) for .go files. Mirrors the go-coding router skill for Cursor. globs: ["**/*.go"] alwaysApply: false --- @@ -16,13 +16,50 @@ This Cursor rule mirrors the `go-coding` router skill — apply it when editing | Topic | Run (deterministic) | Deeper skill | |---|---|---| | Formatting | `gofmt`/`gofumpt` (+ `goimports`) — machine-enforced | — | -| Static analysis / bugs | `go vet ./...`, `golangci-lint run` | `go-linting` | +| Static analysis / bugs | `go vet ./...`, `golangci-lint run` | `go-lint-setup` | | Modern idioms | `go fix ./...` (the toolchain's modernizers), or `golangci-lint run --enable-only=modernize` | `go-idioms` | | Errors | `golangci-lint run --enable-only=errorlint,exhaustive` | `go-errors` | | Concurrency | `go test -race ./...`, `go vet ./...` | `go-concurrency` | | Testing | `go test -race ./...`; `testing/synctest` for time/concurrency | `go-testing` | | Layout, naming & API surface | `golangci-lint run --enable-only=revive`; rest is judgment | `go-layout` | +## Route, then load + +This rule is an index, not the standard. Before writing or reviewing Go, open the focused skill +matching the change — it carries the cited rules and the judgment: + +- any `_test.go`, a benchmark, a fuzz target, a "verified by temporarily breaking it" claim → + `go-testing` +- `fmt.Errorf`, `errors.*`, a sentinel, a typed error, a `switch` over an enum → `go-errors` +- a loop, map, slice, string split, `interface{}`, a struct literal that could be `new(expr)` → + `go-idioms` +- `go func`, `chan`, `sync.`, `atomic.`, `errgroup`, `context.With*`, a `Close` on a goroutine-owned + resource → `go-concurrency` +- a new package, an exported identifier, a `cmd/` or `internal/` decision, a doc comment on an API → + `go-layout` +- `.golangci.y*ml`, a linter complaint you do not understand → `go-lint-setup` + +## Minimum checklist (when opening a skill is not affordable) + +- Wrap with `fmt.Errorf("…: %w", err)`; inspect with `errors.Is` / `errors.AsType` and guard the + result (`ok && v != nil` — a typed-nil pointer satisfies the match). Never swallow an error. +- Every guard has a test that fails when the guard is deleted; a "temporarily broke it by hand" + check is not evidence — commit it as a can-fail test. Table-driven `t.Run` with got/want messages. +- `range n`, `min`/`max`, `slices`/`maps`, `strings.Cut`, `any`; `go fix ./...` before hand-edits. +- `ctx` first; no goroutine without an owner that waits for it; `t.Context()` in tests. +- Run `gofmt`/`gofumpt` and `golangci-lint run` — never reason out what a tool decides. + +## Writing for the human + +Anything a person reads — a PR description, a review comment, a question, a design choice put to +them — goes in plain English, not Go shorthand. State the effect before the mechanism ("the request +keeps running after the caller gives up", not "ctx leak in the errgroup"), and expand a term the +first time it appears or leave it out. Keep it short: a few sentences per point, and a decision they +must make gets the options plus a recommendation. Identifiers, commands and linter names stay +verbatim — it is the prose around them that must be plain. + Ground every judgment call in a cited source — Effective Go, Go Code Review Comments, the Google and Uber Go style guides, `pkg.go.dev`. Don't invent rules. Adopt the shipped golangci-lint v2 -config (`references/golangci.v2.yml`, at the **plugin root** — resolve via `${CLAUDE_PLUGIN_ROOT}/references/…` or Glob the installed copy, not under this rule's dir); run `golangci-lint run --fix` for auto-fixable findings. +config (`references/golangci.v2.yml`, at the **plugin root** — search the installed plugin directory +for it, not under this rule's own dir); run `golangci-lint run --fix` for auto-fixable findings, and +read the rest. diff --git a/scripts/hooks-test.sh b/scripts/hooks-test.sh new file mode 100755 index 0000000..3193e64 --- /dev/null +++ b/scripts/hooks-test.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Bash tests for the plugin hooks. Run: ./scripts/hooks-test.sh (exit 0 = all pass) +set -u +here="$(cd "$(dirname "$0")/.." && pwd)"; fails=0 +run_case() { # NAME DIR EXPECT_SUBSTRING (empty = expect no output) + local name="$1" dir="$2" expect="$3" out + out="$(cd "$dir" && bash "$here/hooks/session-start.sh")" + if [ -z "$expect" ]; then [ -z "$out" ] || { echo "FAIL $name: expected silence, got: $out"; fails=$((fails+1)); return; } + else case "$out" in *"$expect"*) ;; *) echo "FAIL $name: missing '$expect' in: $out"; fails=$((fails+1)); return;; esac; fi + echo "ok $name" +} +run_case_absent() { # NAME DIR UNEXPECTED_SUBSTRING + local name="$1" dir="$2" unexpected="$3" out + out="$(cd "$dir" && bash "$here/hooks/session-start.sh")" + case "$out" in *"$unexpected"*) echo "FAIL $name: unexpected '$unexpected' in: $out"; fails=$((fails+1)); return;; esac + echo "ok $name" +} +t="$(mktemp -d)"; trap 'rm -rf "$t"' EXIT +mkdir -p "$t/go-with-lint" "$t/go-no-lint" "$t/not-go" +printf 'module x\n\ngo 1.26.0\n' > "$t/go-with-lint/go.mod"; : > "$t/go-with-lint/.golangci.yml" +printf 'module x\n\ngo 1.26.0\n' > "$t/go-no-lint/go.mod" +run_case "go repo with lint config names the hop" "$t/go-with-lint" "load go-coding then the skill for your diff" +run_case "go repo with lint config names go-reviewer" "$t/go-with-lint" "go-reviewer for a diff review" +run_case_absent "go repo with lint config omits lint-setup" "$t/go-with-lint" "/go-lint-setup" +run_case_absent "banner names no removed skill" "$t/go-no-lint" "go-explain" +run_case "go repo without lint config offers setup" "$t/go-no-lint" "/go-lint-setup" +run_case "non-go repo is silent" "$t/not-go" "" + +# --- skill-nudge ------------------------------------------------------------------------------- +# Payload builders. p_edit/p_write mirror a real Claude Code PostToolUse payload: hook_event_name, +# tool_input with BOTH old_string and new_string, and a tool_response echoing nearby file text. +# That shape is the point — a hook that greps the raw payload passes a new_string-only fixture and +# still misfires in production, where old_string and tool_response carry the code around the edit. +p_edit() { # SESSION FILE OLD NEW RESPONSE + printf '{"session_id":"%s","hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"%s","old_string":"%s","new_string":"%s"},"tool_response":{"filePath":"%s","originalFile":"%s"}}' \ + "$1" "$2" "$3" "$4" "$2" "${5:-}" +} +p_write() { # SESSION FILE CONTENT + printf '{"session_id":"%s","hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"%s","content":"%s"}}' "$1" "$2" "$3" +} +p_path() { # SESSION FILE — Cursor afterFileEdit: a path, no tool_input, no hook_event_name + printf '{"session_id":"%s","file_path":"%s"}' "$1" "$2" +} + +# Every case asserts exit 0 as well as the output: Claude Code treats a non-zero PostToolUse hook as +# a failed hook, and a bare $(...) would swallow that. +hook() { # NAME PAYLOAD -> echoes stdout; flags a non-zero exit + local name="$1" out st + out="$(printf '%s' "$2" | bash "$here/hooks/skill-nudge.sh")"; st=$? + [ "$st" -eq 0 ] || { echo "FAIL $name: hook exited $st (a non-zero PostToolUse hook is a failed hook)"; fails=$((fails+1)); } + printf '%s' "$out" +} +chk() { local name="$1" got="$2" expect="$3"; case "$got" in *"$expect"*) echo "ok $name";; *) echo "FAIL $name: got '$got'"; fails=$((fails+1));; esac; } +chk_silent() { local name="$1" got="$2"; if [ -z "$got" ]; then echo "ok $name"; else echo "FAIL $name: expected silence, got '$got'"; fails=$((fails+1)); fi; } + +s="test-$$"; rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$s."* +: > "$t/a_test.go"; printf 'package a\nfunc f(){ go func(){}() }\n' > "$t/w.go"; printf 'package a\nimport "fmt"\nvar e = fmt.Errorf("x")\n' > "$t/e.go"; : > "$t/plain.go"; : > "$t/readme.md" + +chk "test file nudges go-testing" "$(hook t "$(p_edit "$s" "$t/a_test.go" 'x := 1' 'x := 2')")" "go-coding:go-testing" +chk_silent "second test file is silent" "$(hook t2 "$(p_edit "$s" "$t/a_test.go" 'x := 2' 'x := 3')")" +chk "goroutine edit nudges go-concurrency" "$(hook c "$(p_edit "$s" "$t/w.go" '' 'go func(){}()')")" "go-coding:go-concurrency" +chk "fmt.Errorf edit nudges go-errors" "$(hook e "$(p_edit "$s" "$t/e.go" '' 'return fmt.Errorf(\"x: %w\", err)')")" "go-coding:go-errors" +chk "Write content nudges too" "$(hook w "$(p_write "$s-w" "$t/w.go" 'package a\nfunc f(){ go func(){}() }')")" "go-coding:go-concurrency" +chk_silent "plain go file is silent" "$(hook p "$(p_edit "$s" "$t/plain.go" '' 'const x = 1')")" +chk_silent "non-go file is silent" "$(hook n "$(p_edit "$s" "$t/readme.md" '' 'fmt.Errorf')")" +rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$s."* "${TMPDIR:-/tmp}/go-coding-nudge.$s-w."* + +# The whole point of classifying from the edit. Each of these payloads carries fmt.Errorf somewhere +# a naive grep would find it — the old text being deleted, the tool_response echo, the file on disk +# — while the text the edit ADDS is a doc-comment tidy. All must stay silent. +sedit="$s-edit"; rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$sedit."* +chk_silent "edit that only deletes an error path is silent" \ + "$(hook d "$(p_edit "$sedit" "$t/e.go" 'return fmt.Errorf(\"x\")' 'return nil')")" +# Its own session: otherwise the case above would have consumed the go-errors marker and this one +# would pass by dedupe rather than by classifying correctly. +chk_silent "edit beside an error path is silent" \ + "$(hook b "$(p_edit "$sedit-beside" "$t/e.go" '// old comment' '// tidy the doc comment' 'package a\nimport \"fmt\"\nvar e = fmt.Errorf(\"x\")')")" +chk "path-only payload falls back to the file" "$(hook f "$(p_path "$sedit" "$t/e.go")")" "go-coding:go-errors" +chk "path-only payload says 'this file'" "$(hook f2 "$(p_path "$sedit-2" "$t/e.go")")" "this file touches" +chk "edit payload says 'this edit'" "$(hook f3 "$(p_edit "$sedit-3" "$t/a_test.go" '' 'x := 1')")" "this edit touches" +rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$sedit"* + +# Delivery channel: a fresh fixture + session per case so dedupe cannot silence it, and each host +# path forced explicitly rather than relying on the ambient CLAUDE_PLUGIN_ROOT. +printf 'package a\nimport "fmt"\nvar e = fmt.Errorf("x")\n' > "$t/e2.go" +sjson="$s-json"; rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$sjson."* +out="$(CLAUDE_PLUGIN_ROOT=/x hook j "$(p_edit "$sjson" "$t/e2.go" '' 'fmt.Errorf')")" +chk "CLAUDE_PLUGIN_ROOT delivers a systemMessage" "$out" '{"systemMessage":' +rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$sjson."* + +# hook_event_name alone must be enough — CLAUDE_PLUGIN_ROOT is not set for hooks in every context, +# and the nudge has to carry the skill name, not just be well-formed JSON. +shook="$s-hookevent"; rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$shook."* +out="$(printf '%s' "$(p_edit "$shook" "$t/e2.go" '' 'fmt.Errorf')" | env -u CLAUDE_PLUGIN_ROOT bash "$here/hooks/skill-nudge.sh")"; st=$? +[ "$st" -eq 0 ] || { echo "FAIL hook_event_name without CLAUDE_PLUGIN_ROOT: exited $st"; fails=$((fails+1)); } +chk "hook_event_name alone delivers a systemMessage" "$out" '{"systemMessage":' +chk "that systemMessage names the skill" "$out" "go-coding:go-errors" +rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$shook."* + +scursor="$s-cursor"; rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$scursor."* +# Built directly (not via hook()): env -u only strips CLAUDE_PLUGIN_ROOT from an external-command +# invocation, and the path-only payload already lacks hook_event_name. +out="$(printf '%s' "$(p_path "$scursor" "$t/e2.go")" | env -u CLAUDE_PLUGIN_ROOT bash "$here/hooks/skill-nudge.sh")"; st=$? +[ "$st" -eq 0 ] || { echo "FAIL nudge is a plain line under Cursor: exited $st"; fails=$((fails+1)); } +case "$out" in + '{'*) echo "FAIL nudge is a plain line under Cursor: got JSON '$out'"; fails=$((fails+1));; + *"go-coding:go-errors"*) echo "ok nudge is a plain line under Cursor";; + *) echo "FAIL nudge is a plain line under Cursor: got '$out'"; fails=$((fails+1));; +esac +rm -f "${TMPDIR:-/tmp}/go-coding-nudge.$scursor."* + +# --- format-on-save ---------------------------------------------------------------------------- +# Needs a formatter; without gofmt or gofumpt on PATH the hook is a deliberate no-op and there is +# nothing to assert beyond "exits 0", which the last case covers. +fmt_hook() { # PAYLOAD -> exit status recorded, stdout dropped + printf '%s' "$1" | bash "$here/hooks/format-on-save.sh" >/dev/null 2>&1 +} +if command -v gofmt >/dev/null 2>&1 || command -v gofumpt >/dev/null 2>&1; then + printf 'package a\nfunc f( ) {\n}\n' > "$t/messy.go" + before="$(cat "$t/messy.go")" + fmt_hook "$(p_write "$s-fmt" "$t/messy.go" 'x')"; st=$? + after="$(cat "$t/messy.go")" + if [ "$st" -ne 0 ]; then echo "FAIL format-on-save exits 0: exited $st"; fails=$((fails+1)); + elif [ "$before" = "$after" ]; then echo "FAIL format-on-save reformats a messy Go file: file unchanged"; fails=$((fails+1)); + else echo "ok format-on-save reformats a messy Go file"; fi + + # A second run must be a fixpoint — a formatter that keeps rewriting an already-formatted file + # would churn every save. + fmt_hook "$(p_write "$s-fmt" "$t/messy.go" 'x')" + if [ "$(cat "$t/messy.go")" = "$after" ]; then echo "ok format-on-save is idempotent"; else echo "FAIL format-on-save is idempotent: second run changed the file"; fails=$((fails+1)); fi + + printf 'not go source\n' > "$t/keep.md" + fmt_hook "$(p_write "$s-fmt" "$t/keep.md" 'x')" + if [ "$(cat "$t/keep.md")" = 'not go source' ]; then echo "ok format-on-save leaves a non-go file alone"; else echo "FAIL format-on-save leaves a non-go file alone: file was rewritten"; fails=$((fails+1)); fi +else + echo "ok format-on-save cases skipped (no gofmt/gofumpt on PATH)" +fi + +# No formatter on PATH at all: the hook must stay silent and still exit 0. CLAUDE_FILE_PATH is set +# so the hook never needs cat/grep/sed, which an empty PATH would also take away — this case is +# about the missing formatter, not about a crippled shell. +out="$(CLAUDE_FILE_PATH="$t/plain.go" PATH=/nonexistent "$BASH" "$here/hooks/format-on-save.sh" &1)"; st=$? +if [ "$st" -eq 0 ] && [ -z "$out" ]; then echo "ok format-on-save is a silent no-op without a formatter"; else echo "FAIL format-on-save is a silent no-op without a formatter: exit $st, output '$out'"; fails=$((fails+1)); fi + +# Can-fail control (F3): prove chk_silent and run_case_absent actually fail on bad input, so a +# broken helper (e.g. always echoing "ok") can't hide a real regression above. Each probe runs in a +# `$(...)` subshell — already isolated from this shell's `fails` — with its own `fails=0` so the +# probe's internal increment never has a chance to leak into the suite's real count. +selftest() { local name="$1" out="$2"; case "$out" in FAIL*) echo "ok $name";; *) echo "FAIL $name: helper did not fail on bad input: '$out'"; fails=$((fails+1));; esac; } +selftest "can-fail: chk_silent rejects non-empty" "$( fails=0; chk_silent "probe" "not-empty" )" +selftest "can-fail: run_case_absent rejects presence" "$( fails=0; run_case_absent "probe" "$t/go-no-lint" "/go-lint-setup" )" + +[ "$fails" -eq 0 ] || exit 1 diff --git a/scripts/usage-report.py b/scripts/usage-report.py new file mode 100755 index 0000000..ad1b45f --- /dev/null +++ b/scripts/usage-report.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Measure go-coding skill/agent adoption from local Claude Code transcripts. + +Scans Claude Code session transcripts (JSONL files under a projects directory, +one subdirectory per project) for real invocations of this plugin's skills and +its `go-reviewer` agent, then renders a Markdown adoption report. + +An event is counted when a transcript line is one of: + + * an ``assistant`` message containing a ``tool_use`` block with + ``name == "Skill"`` and ``input.skill`` starting with ``go-coding:``; + * an ``assistant`` message containing a ``tool_use`` block with + ``name`` in (``Task``, ``Agent``) and + ``input.subagent_type == "go-coding:go-reviewer"``; + * a ``user`` message whose text contains a ``...`` + invocation naming one of this plugin's skills (bare, e.g. ``/go-lint-setup``, + or namespaced, e.g. ``/go-coding:go-lint-setup``). + +Session-start banner text and skill-body text echoed back inside tool results +are NOT scanned for matches — only structured ``tool_use`` blocks and user +```` invocations count. Each matching line contributes one event, +attributed to "main" or "subagent" via the transcript's ``isSidechain`` field +(subagent transcripts also live under a ``/subagents/`` path). + +Usage: + python3 scripts/usage-report.py [--projects-dir DIR] [--since YYYY-MM-DD] [--out FILE] + +stdlib only — no third-party imports. +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path + +# Skill directory names this plugin ships (used to recognize a bare, unnamespaced +# slash-command invocation such as "/go-lint-setup" as a go-coding command). +# "go-explain" and "go-linting" were removed in 0.5.0 but stay listed so a scan of +# older transcripts still resolves the events they produced. +GO_SKILL_SHORT_NAMES = { + "go-coding", "go-concurrency", "go-errors", "go-explain", "go-idioms", + "go-layout", "go-linting", "go-lint-setup", "go-testing", +} +GO_REVIEWER_SUBAGENT_TYPE = "go-coding:go-reviewer" + +SKILL_PREFIX_RE = re.compile(r"^go-coding:") +COMMAND_NAME_RE = re.compile(r"\s*(.*?)\s*") + +TOOL_USE_AGENT_NAMES = ("Task", "Agent") + + +class Event: + """One counted invocation.""" + + __slots__ = ("name", "month", "source", "session_id") + + def __init__(self, name: str, month: str, source: str, session_id: str | None): + self.name = name + self.month = month + self.source = source # "main" | "subagent" | "user" + self.session_id = session_id + + +def month_of(ts: str | None) -> str: + if not ts or len(ts) < 7: + return "unknown" + return ts[:7] # YYYY-MM + + +def iter_transcript_files(projects_dir: Path): + """Yield (path, is_subagent_file) for every session transcript under projects_dir.""" + if not projects_dir.is_dir(): + return + for project_dir in sorted(p for p in projects_dir.iterdir() if p.is_dir()): + for f in sorted(project_dir.glob("*.jsonl")): + yield f, False + for f in sorted(project_dir.glob("*/subagents/*.jsonl")): + yield f, True + + +def canonical_command_name(raw: str) -> str | None: + """Return the canonical `go-coding:` name for a invocation, + or None if it does not name a go-coding skill.""" + cmd = raw.strip().lstrip("/") + if not cmd: + return None + base = cmd.split(":")[-1] + if cmd.startswith("go-coding:"): + return "go-coding:" + base + if base in GO_SKILL_SHORT_NAMES: + return "go-coding:" + base + return None + + +def user_texts(message: dict) -> list[str]: + content = message.get("content") + if isinstance(content, str): + return [content] + texts = [] + if isinstance(content, list): + for c in content: + if isinstance(c, dict) and c.get("type") == "text": + texts.append(c.get("text", "")) + return texts + + +def scan(projects_dir: Path, since: datetime | None): + """Scan all transcripts, returning (events, session_ids_with_event, files_scanned).""" + events: list[Event] = [] + sessions_with_event: set[str] = set() + files_scanned = 0 + + for fpath, _is_subagent_file in iter_transcript_files(projects_dir): + files_scanned += 1 + try: + with fpath.open("r", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except (json.JSONDecodeError, ValueError): + continue + + ts = obj.get("timestamp") + if since is not None: + parsed = parse_iso(ts) + if parsed is not None and parsed < since: + continue + + sid = obj.get("sessionId") + is_sidechain = bool(obj.get("isSidechain", False)) + typ = obj.get("type") + month = month_of(ts) + + if typ == "assistant": + message = obj.get("message") or {} + content = message.get("content") + if not isinstance(content, list): + continue + for c in content: + if not isinstance(c, dict) or c.get("type") != "tool_use": + continue + tool_name = c.get("name") + inp = c.get("input") or {} + if tool_name == "Skill": + skill = inp.get("skill", "") + if isinstance(skill, str) and SKILL_PREFIX_RE.match(skill): + source = "subagent" if is_sidechain else "main" + events.append(Event(skill, month, source, sid)) + if sid: + sessions_with_event.add(sid) + elif tool_name in TOOL_USE_AGENT_NAMES: + subagent_type = inp.get("subagent_type", "") + if subagent_type == GO_REVIEWER_SUBAGENT_TYPE: + source = "subagent" if is_sidechain else "main" + events.append(Event(subagent_type, month, source, sid)) + if sid: + sessions_with_event.add(sid) + + elif typ == "user": + message = obj.get("message") or {} + for text in user_texts(message): + for m in COMMAND_NAME_RE.finditer(text): + canon = canonical_command_name(m.group(1)) + if canon: + events.append(Event(canon, month, "user", sid)) + if sid: + sessions_with_event.add(sid) + except OSError as e: + print(f"WARNING: could not read {fpath}: {e}", file=sys.stderr) + + return events, sessions_with_event, files_scanned + + +def parse_iso(ts: str | None): + if not ts: + return None + try: + # Transcript timestamps are ISO-8601 UTC, e.g. "2026-08-26T22:24:05.213Z". + return datetime.fromisoformat(ts.replace("Z", "+00:00")) + except ValueError: + return None + + +def aggregate(events: list[Event]): + """skill -> {"total": n, "main": n, "subagent": n, "user": n, "months": {month: n}}""" + agg: dict[str, dict] = defaultdict(lambda: { + "total": 0, "main": 0, "subagent": 0, "user": 0, "months": defaultdict(int), + }) + for e in events: + row = agg[e.name] + row["total"] += 1 + row[e.source] += 1 + row["months"][e.month] += 1 + + session_counts: dict[str, set] = defaultdict(set) + for e in events: + if e.session_id: + session_counts[e.name].add(e.session_id) + + return agg, session_counts + + +def render_markdown(agg, session_counts, events, sessions_with_event, files_scanned, + projects_dir: Path, since: datetime | None) -> str: + lines = [] + lines.append("# go-coding usage report") + lines.append("") + generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + lines.append(f"Generated: {generated}") + lines.append(f"Projects dir: `{projects_dir}`") + lines.append(f"Since: {since.date().isoformat() if since else '(all time)'}") + lines.append(f"Transcript files scanned: {files_scanned}") + lines.append(f"Total events: {len(events)}") + lines.append("") + + all_months = sorted({m for row in agg.values() for m in row["months"] if m != "unknown"}) + + lines.append("## Per skill / agent") + lines.append("") + header = ["Skill/Agent", "Total", "Main", "Subagent", "User"] + all_months + lines.append("| " + " | ".join(header) + " |") + lines.append("|" + "|".join(["---"] * len(header)) + "|") + for name in sorted(agg.keys()): + row = agg[name] + cells = [ + name, + str(row["total"]), + str(row["main"]), + str(row["subagent"]), + str(row["user"]), + ] + [str(row["months"].get(m, 0)) for m in all_months] + lines.append("| " + " | ".join(cells) + " |") + if not agg: + lines.append("| _(no events found)_ | | | | |" + "".join(" |" for _ in all_months)) + lines.append("") + + lines.append("## Sessions with >=1 event, per skill / agent") + lines.append("") + lines.append("| Skill/Agent | Sessions |") + lines.append("|---|---|") + for name in sorted(session_counts.keys()): + lines.append(f"| {name} | {len(session_counts[name])} |") + if not session_counts: + lines.append("| _(no events found)_ | |") + lines.append("") + + lines.append(f"Distinct sessions with >=1 go-coding event (any skill/agent): {len(sessions_with_event)}") + lines.append("") + + return "\n".join(lines) + "\n" + + +def build_arg_parser() -> argparse.ArgumentParser: + epilog = """\ +Counting rules: + Counted: + - assistant tool_use block, name "Skill", input.skill starting "go-coding:" + - assistant tool_use block, name "Task" or "Agent", input.subagent_type + == "go-coding:go-reviewer" + - user invocations naming a go-coding skill (bare, e.g. + "/go-lint-setup", or namespaced, e.g. "/go-coding:go-lint-setup") + Not counted: + - the SessionStart banner text + - skill-body text echoed back inside tool results + Each transcript session is identified by its sessionId; a session's events are + attributed to "main" or "subagent" via the transcript's isSidechain field. +""" + parser = argparse.ArgumentParser( + description="Measure go-coding skill/agent adoption from local Claude Code transcripts.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=epilog, + ) + parser.add_argument( + "--projects-dir", + type=Path, + default=Path.home() / ".claude" / "projects", + help="Directory containing Claude Code project transcript folders " + "(default: ~/.claude/projects)", + ) + parser.add_argument( + "--since", + type=str, + default=None, + metavar="YYYY-MM-DD", + help="Only count events at or after this date (default: all time)", + ) + parser.add_argument( + "--out", + type=Path, + default=None, + metavar="FILE", + help="Write the Markdown report to FILE (default: print to stdout)", + ) + return parser + + +def main(argv=None) -> int: + parser = build_arg_parser() + args = parser.parse_args(argv) + + since = None + if args.since: + try: + since = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + print(f"error: --since must be YYYY-MM-DD, got {args.since!r}", file=sys.stderr) + return 2 + + projects_dir: Path = args.projects_dir + if not projects_dir.is_dir(): + print(f"No transcripts found: projects directory does not exist: {projects_dir}") + return 0 + + events, sessions_with_event, files_scanned = scan(projects_dir, since) + + if files_scanned == 0: + print(f"No transcripts found: no .jsonl files under {projects_dir}") + return 0 + + agg, session_counts = aggregate(events) + report = render_markdown( + agg, session_counts, events, sessions_with_event, files_scanned, projects_dir, since, + ) + + if args.out: + args.out.write_text(report) + print(f"Wrote report to {args.out}") + else: + print(report) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate.py b/scripts/validate.py index ab4f8a7..fe255f0 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -21,14 +21,25 @@ This plugin has no MCP backend, so there is intentionally no ``.mcp.json`` check. + * dual-host hook parity: the same ``hooks/*.sh`` wired for the equivalent event on both + hosts, each wired script present and executable, and none left unwired; + * doc component inventories: every shipped skill, agent and hook named in the docs that + claim to list them. One-directional, so tombstones for removed components stay legal. + Dependency-free (stdlib only) so the ``scripts/validate.sh`` soft-skip is the *only* -reason it wouldn't run. Usage: python3 scripts/validate.py (from the repo root) +reason it wouldn't run. + +Usage: + python3 scripts/validate.py # verify this tree + python3 scripts/validate.py --selftest # verify the checks themselves still catch things """ import json +import os import re import shutil import subprocess import sys +import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -49,6 +60,18 @@ # minor's `go tool fix help` — a newer/older toolchain's list would prove nothing about the # floor, so the check skips on any other minor. Bump together with the documented baseline. GO_FLOOR_MINOR = "1.26" +# Hook events that mean the same thing on each host, so the same scripts must be wired for +# both. Claude event -> Cursor event. +EQUIVALENT_HOOK_EVENTS = {"SessionStart": "sessionStart", "PostToolUse": "afterFileEdit"} +# Docs that inventory the component surface, and which kinds each one claims to cover. +# install.md enumerates the hooks (it documents what they need on PATH) but is not a skill +# catalogue, so it is held to the hook list only. +INVENTORY_DOCS = { + "README.md": ("skills", "agents", "hooks"), + "AGENTS.md": ("skills", "agents", "hooks"), + "docs/testing.md": ("skills", "agents", "hooks"), + "docs/install.md": ("hooks",), +} def err(msg): @@ -187,7 +210,7 @@ def validate_linter_references(): taught.update(re.findall(r"[Tt]he\s+`([a-z0-9-]+)`\s+linter", body)) for name in sorted(taught - allowed): err(f"{md.relative_to(ROOT)}: teaches the '{name}' linter but " - f"references/golangci.v2.yml does not enable it — enable it in all three " + f"references/golangci.v2.yml does not enable it — enable it in both " f"config copies or stop naming it (advice == tooling)") @@ -274,6 +297,90 @@ def validate_json_file(path: Path, label: str): load_json(path, label) +def _hook_scripts(config: dict) -> dict: + """Map each event name in a hook config to the set of `hooks/*.sh` scripts it wires. + Both host schemas nest differently (Claude groups by matcher, Cursor does not), so walk + whatever is under the event and collect every `command` string found.""" + found = {} + for event, entries in (config.get("hooks") or {}).items(): + scripts = set() + stack = [entries] + while stack: + node = stack.pop() + if isinstance(node, dict): + cmd = node.get("command") + if isinstance(cmd, str): + scripts.update(re.findall(r"hooks/([A-Za-z0-9._-]+\.sh)", cmd)) + stack.extend(node.values()) + elif isinstance(node, list): + stack.extend(node) + found[event] = scripts + return found + + +def validate_hook_parity(): + """Dual-host hook parity: the same scripts must be wired for the equivalent event on both + hosts, every wired script must exist and be executable, and no `hooks/*.sh` may sit in the + tree unwired. Catches the drift where a new hook reaches Claude Code but never Cursor — + previously only findable by reading both configs side by side.""" + claude_path, cursor_path = ROOT / "hooks" / "hooks.json", ROOT / "hooks" / "cursor-hooks.json" + if not (claude_path.is_file() and cursor_path.is_file()): + return + claude, cursor = load_json(claude_path, "Claude hooks"), load_json(cursor_path, "Cursor hooks") + if claude is None or cursor is None: + return + claude_events, cursor_events = _hook_scripts(claude), _hook_scripts(cursor) + wired = set() + for claude_event, cursor_event in EQUIVALENT_HOOK_EVENTS.items(): + here, there = claude_events.get(claude_event, set()), cursor_events.get(cursor_event, set()) + wired |= here | there + for name in sorted(here - there): + err(f"hooks/cursor-hooks.json: '{name}' is wired for Claude's {claude_event} but " + f"not for Cursor's {cursor_event} (dual-host parity)") + for name in sorted(there - here): + err(f"hooks/hooks.json: '{name}' is wired for Cursor's {cursor_event} but not for " + f"Claude's {claude_event} (dual-host parity)") + for event in set(claude_events) - set(EQUIVALENT_HOOK_EVENTS): + wired |= claude_events[event] + for event in set(cursor_events) - set(EQUIVALENT_HOOK_EVENTS.values()): + wired |= cursor_events[event] + for name in sorted(wired): + script = ROOT / "hooks" / name + if not script.is_file(): + err(f"hooks: wired script 'hooks/{name}' does not exist") + elif not os.access(script, os.X_OK): + err(f"hooks/{name}: wired but not executable (chmod +x)") + for script in sorted((ROOT / "hooks").glob("*.sh")): + if script.name not in wired: + err(f"hooks/{script.name}: present in the tree but wired by neither host's hook config") + + +def validate_doc_inventories(): + """Every shipped component must appear in the docs that claim to inventory the surface. + A removed skill leaves stale rows behind and a new hook goes unmentioned — both happened in + this repo. The check is one-directional on purpose: it proves each component IS documented, + not that every name mentioned still exists, so deliberate tombstones ("removed in 0.5.0") + and cross-references stay legal.""" + # Hooks are matched on the stem, since docs legitimately write "the format-on-save hook". + kinds = { + "skills": [d.name for d in sorted((ROOT / "skills").iterdir()) if (d / "SKILL.md").is_file()], + "agents": [m.stem for m in sorted((ROOT / "agents").glob("*.md"))], + "hooks": [s.stem for s in sorted((ROOT / "hooks").glob("*.sh"))], + } + for doc_name, covered in INVENTORY_DOCS.items(): + doc = ROOT / doc_name + if not doc.is_file(): + continue + body = doc.read_text() + for kind in covered: + for component in kinds[kind]: + # Bounded so a shorter name is not satisfied by a longer one that contains it + # ("go-test" must not be answered by "go-testing"). + if not re.search(rf"(? int: + """Every structural check, run against a tree built to break it. A check that has quietly + stopped checking — a renamed field, a regex that no longer matches — still exits 0 on a valid + tree, so 'the suite is green' proves nothing on its own. Each case is also run against the + same tree with the defect removed, so a check that always fires fails too.""" + global ROOT, errors + real_root, real_errors, failures = ROOT, errors, 0 + for label, defect, checks in SELFTEST_CASES: + outcomes = {} + for variant, break_it in (("broken", defect), ("clean", None)): + with tempfile.TemporaryDirectory() as tmp: + ROOT = Path(tmp) + _selftest_tree(ROOT, break_it=break_it) + errors = [] + for check in checks: + check() + outcomes[variant] = list(errors) + ROOT, errors = real_root, real_errors + if not outcomes["broken"]: + print(f"FAIL {label}: the check did not catch it") + failures += 1 + elif outcomes["clean"]: + print(f"FAIL {label}: the check also fires on a clean tree: {outcomes['clean'][0]}") + failures += 1 + else: + print(f"ok {label}") + if failures: + print(f"FAIL: {failures} check(s) do not actually check") + return 1 + print(f"OK: {len(SELFTEST_CASES)} structural checks each caught their own failure case") + return 0 if __name__ == "__main__": + if "--selftest" in sys.argv[1:]: + sys.exit(run_selftest()) main() if errors: print(f"FAIL: {len(errors)} problem(s)") @@ -322,6 +520,7 @@ def main(): print(f" - {e}") sys.exit(1) print("OK: manifests, dual-host parity, component paths, kebab-case names, " - "hook configs, skills, agents, commands, rules, and taught-linter references are valid") + "hook configs and hook parity, skills, agents, commands, rules, taught-linter " + "references, and doc component inventories are valid") for note in notes: print(f" note: {note}") diff --git a/skills/go-coding/SKILL.md b/skills/go-coding/SKILL.md index fc6715f..e24c9d7 100644 --- a/skills/go-coding/SKILL.md +++ b/skills/go-coding/SKILL.md @@ -1,6 +1,6 @@ --- name: go-coding -description: Go coding-standards router for idiomatic Go (Go 1.26.4+; golangci-lint v2). This skill should be used when a Go task spans multiple areas, is unspecified, or the question is which tool or standard applies — it routes each topic to the deterministic tool, then to the focused go-* skill that owns it (go-errors, go-concurrency, go-testing, go-idioms, go-linting, go-layout for layout/naming/API design). For a single, already-identified topic load that skill directly. Not for non-Go languages or domain/business rules. +description: Go coding-standards router — Go 1.26.4+ (1.27 supported, its additions flagged as hints), golangci-lint v2. This skill should be used when a Go task spans several areas, is unspecified, or the question is which tool or standard applies — it names the deterministic tool to run, then the focused skill that owns the topic (go-errors, go-concurrency, go-testing, go-idioms, go-layout, go-lint-setup). Loading the router alone does not apply the standards — load the skill it names next. For one already-identified topic, load that skill directly. Go only; not for business rules. --- # go-coding — Go standards router @@ -18,7 +18,7 @@ Two principles from the project research drive it: | Topic | Run now (deterministic) | Deeper skill | |---|---|---| | Formatting | `gofmt -l` / `gofumpt -l` (+ `goimports`) — machine-enforced, non-negotiable | — | -| Static analysis / likely bugs | `go vet ./...`, `golangci-lint run` | `go-linting` | +| Static analysis / likely bugs | `go vet ./...`, `golangci-lint run` | `go-lint-setup` | | Modern idioms (range-int, `min`/`max`, `slices`/`maps`, `wg.Go`, `strings.Cut`, `new(expr)`, `errors.AsType`) | `go fix ./...` (the toolchain's modernizer suite), or `golangci-lint run --enable-only=modernize` for CI reproducibility | `go-idioms` | | Errors (`%w`, `errors.Is`/`AsType`, `errors.Join`, sentinel/typed, enum dispatch) | `golangci-lint run --enable-only=errorlint,exhaustive` | `go-errors` | | Concurrency (goroutine leaks, ctx lifecycle, atomics) | `go test -race ./...`, `go vet ./...` | `go-concurrency` | @@ -29,6 +29,45 @@ Two principles from the project research drive it: Open the focused `go-*` skill for the topic — it carries the cited rules and the judgment; run the tool in the middle column to enforce them. Don't invent rules: each skill cites its sources. +## Route, then load + +The router is an index, not the standard. Before writing or reviewing Go you MUST load the focused +skill matching the change — with the Skill tool (`go-coding:go-errors`, …), not by recalling it: + +| The diff touches… | Load | +|---|---| +| any `_test.go`, a benchmark, a fuzz target, a "verified by temporarily breaking it" claim | `go-testing` | +| `fmt.Errorf`, `errors.*`, a sentinel, a typed error, a `switch` over an enum | `go-errors` | +| a loop, map, slice, string split, `interface{}`, a struct literal that could be `new(expr)` | `go-idioms` | +| `go func`, `chan`, `sync.`, `atomic.`, `errgroup`, `context.With*`, a `Close` on a goroutine-owned resource | `go-concurrency` | +| a new package, an exported identifier, a `cmd/` or `internal/` decision, a doc comment on an API | `go-layout` | +| `.golangci.y*ml`, a linter complaint you do not understand | `go-lint-setup` | + +One load per skill per session is enough; the skill stays in context. Orchestrators dispatching +implementer or reviewer subagents carry this table into every brief — a subagent does not inherit +the parent session's skills. + +## Minimum checklist (when a second load is not affordable) + +Apply these even if you load nothing else; they are the rules the focused skills most often catch: + +- Wrap with `fmt.Errorf("…: %w", err)`; inspect with `errors.Is` / `errors.AsType` and guard the + result (`ok && v != nil` — a typed-nil pointer satisfies the match). Never swallow an error. +- Every guard has a test that fails when the guard is deleted; a "temporarily broke it by hand" + check is not evidence — commit it as a can-fail test. Table-driven `t.Run` with got/want messages. +- `range n`, `min`/`max`, `slices`/`maps`, `strings.Cut`, `any`; `go fix ./...` before hand-edits. +- `ctx` first; no goroutine without an owner that waits for it; `t.Context()` in tests. +- Run `gofmt`/`gofumpt` and `golangci-lint run` — never reason out what a tool decides. + +## Writing for the human + +Anything a person reads — a PR description, a review comment, a question, a design choice put to +them — goes in plain English, not Go shorthand. State the effect before the mechanism ("the request +keeps running after the caller gives up", not "ctx leak in the errgroup"), and expand a term the +first time it appears or leave it out. Keep it short: a few sentences per point, and a decision they +must make gets the options plus a recommendation, not an essay. Identifiers, commands and linter +names stay verbatim — it is the prose around them that must be plain. + ## Authoritative sources (cite, don't guess) - Effective Go — @@ -42,8 +81,11 @@ tool in the middle column to enforce them. Don't invent rules: each skill cites Dispatch the `go-reviewer` agent — a report-only, context-isolated reviewer that applies the review-heuristics catalog and returns severity-ranked findings on a diff or file. -Two user-invoked skills round out the surface: `/go-explain ` for a one-shot idiom lookup, -and `/go-lint-setup` to scaffold the reference golangci-lint v2 config into a repo. +If a workflow already owns the reviewer seat, that reviewer loads the focused skills itself instead — +one review seat per diff. Orchestrators: put the "Route, then load" table into every implementer and +reviewer brief. + +`/go-lint-setup` scaffolds the reference golangci-lint v2 config into a repo. --- *Top-level structure adapted from [`samber/cc-skills-golang`](https://github.com/samber/cc-skills-golang) (MIT © 2026 Samuel Berthe).* diff --git a/skills/go-concurrency/SKILL.md b/skills/go-concurrency/SKILL.md index 11ff072..2f792fe 100644 --- a/skills/go-concurrency/SKILL.md +++ b/skills/go-concurrency/SKILL.md @@ -1,6 +1,6 @@ --- name: go-concurrency -description: Idiomatic, leak-free Go concurrency. This skill should be used when the user writes or reviews Go goroutines, channels, `sync`/`atomic`, `context`, `errgroup`, or worker pools — goroutine lifetimes/leaks, context propagation, cancellation causes (`context.Cause`, `WithTimeoutCause`), work that must outlive a request (`context.WithoutCancel`, `AfterFunc`), typed atomics, mutex misuse, cleanup/finalizers, or data races. Pair with `go test -race`, `go vet`, and `goleak`. Defers time/concurrency *testing* mechanics to `go-testing` (synctest). Not for non-Go languages. +description: Idiomatic, leak-free Go concurrency. This skill should be used when a diff or question contains go func, chan, select, sync.WaitGroup/Mutex/Once, atomic, errgroup, context.WithCancel/Timeout/Cause, a retry or backoff loop, a worker pool, per-request cancellation, or a Close on a goroutine-owned resource — goroutine lifetime and leaks, context propagation and cancel causes, work outliving a request, typed atomics, mutex misuse, data races. Pair with go test -race and goleak. Time-dependent tests belong to go-testing (synctest). Go only. --- # go-concurrency — Go concurrency @@ -8,11 +8,12 @@ description: Idiomatic, leak-free Go concurrency. This skill should be used when Deterministic backstop: `go test -race ./...`, `go vet ./...` (catches copylocks, lost cancel), and `go.uber.org/goleak`. The race detector is the source of truth — run it before reasoning. The runtime also ships an experimental `goroutineleak` profile in `runtime/pprof` (Go 1.26) -that reports leaked goroutines — a toolchain-native complement to `goleak` for leak hunts (enable it -with `GOEXPERIMENT=goroutineleakprofile` at build time). The implementation is production-ready; the -experiment flag is only about API feedback, and it costs nothing unless in use. -*Go 1.27 (draft, expected Aug 2026) enables it by default — no `GOEXPERIMENT`, and -`/debug/pprof/goroutineleak` via `net/http/pprof`.* +that reports leaked goroutines — a toolchain-native complement to `goleak` for leak hunts (on Go +1.26, enable it with `GOEXPERIMENT=goroutineleakprofile` at build time). The implementation is +production-ready; the experiment flag is only about API feedback, and it costs nothing unless in use. +*On Go 1.27 the profile is generally available with no build flag — read it via +`pprof.Lookup("goroutineleak")` or the `net/http/pprof` endpoint `/debug/pprof/goroutineleak`. Source: +; .* ## Rules diff --git a/skills/go-errors/SKILL.md b/skills/go-errors/SKILL.md index ffb90cc..5b29ceb 100644 --- a/skills/go-errors/SKILL.md +++ b/skills/go-errors/SKILL.md @@ -1,6 +1,6 @@ --- name: go-errors -description: Idiomatic Go error handling. This skill should be used when the user writes, reviews, or debugs Go error code — wrapping with `%w`, inspecting via `errors.Is`/`errors.AsType`, sentinel vs typed errors, `errors.Join`, unchecked `Close` errors, when panic is legitimate, enum-switch dispatch defaults, keeping payload values out of boundary errors/logs, or chasing a silently-swallowed or context-losing error. Pair with the `errorlint` linter (set it up via `go-linting`). Not for non-Go languages. +description: Idiomatic Go error handling. This skill should be used when the user writes, reviews, or debugs Go error code — wrapping with `%w`, `errors.Is`/`errors.AsType`, sentinel vs typed errors, `errors.Join`, an unchecked `Close`, when panic is legitimate, enum-switch dispatch defaults, keeping payload values out of boundary errors and logs, or chasing a swallowed or context-losing error. Pair with the `errorlint` linter. Go only. --- # go-errors — Go error handling diff --git a/skills/go-explain/SKILL.md b/skills/go-explain/SKILL.md deleted file mode 100644 index 9fdb3e5..0000000 --- a/skills/go-explain/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: go-explain -description: One-shot lookup/explanation of a single Go idiom, standard, or tool. This skill should be used when the user runs `/go-explain ` or asks to "explain", "look up", or "what's the modern way to do" a specific Go construct (e.g. error wrapping, `synctest`, `wg.Go`, `internal/` layout) — returning the modern form, the enforcing linter, and a cited source. For applying a standard while writing or reviewing code, use the focused go-* skill instead. Not for non-Go languages. -argument-hint: a Go topic (e.g. error wrapping, synctest, wg.Go, min/max, internal layout) -allowed-tools: Read, Grep, Glob ---- - -# go-explain — one-shot Go lookup - -Explain the Go idiom, standard, or tool named in **$ARGUMENTS** — concisely, in one interaction. - -Cover, in a few lines: - -1. **The modern form** — what idiomatic Go does today, with the Go version it landed in. -2. **Over what** — the older pattern it replaces, if any. -3. **Enforcement** — the tool that flags or fixes it (`gofmt`/`gofumpt`, `go vet`, a `golangci-lint` - linter such as `modernize`/`errorlint`, or `go test -race`), with the exact command to run. -4. **Source** — cite one authoritative reference: Effective Go, Go Code Review Comments, the Google - or Uber Go style guide, a `go.dev/blog` post, or `pkg.go.dev`. - -Answer against the **Go 1.26.4+** baseline. Name the version an idiom landed in (that's step 1) — -that is provenance for the reader, not a gate on the recommendation. For a fuller treatment, route -to the matching skill: `go-errors`, `go-concurrency`, -`go-testing`, `go-idioms`, `go-linting`, or `go-layout`. - -Keep it tight — this is a lookup, not a lecture. If `$ARGUMENTS` is empty, ask what to explain. diff --git a/skills/go-idioms/SKILL.md b/skills/go-idioms/SKILL.md index 12eb5bb..342b5f0 100644 --- a/skills/go-idioms/SKILL.md +++ b/skills/go-idioms/SKILL.md @@ -1,6 +1,6 @@ --- name: go-idioms -description: Modern idiomatic Go (the `modernize` analyzer set). This skill should be used when the user writes, reviews, or modernizes Go and wants current-version idioms — range-over-int, `min`/`max`, `slices`/`maps`, `strings.Cut`, `any` over `interface{}`, iterators, `omitzero` json tags, `os.Root`, `new(expr)` and `errors.AsType` (Go 1.26), dropped loop-var copies — or asks which modernize fixer owns a rewrite. Framed so advice equals tooling (`go fix ./...`, or `golangci-lint --enable-only=modernize`). Not for golangci-lint configuration (use `go-linting`) or non-Go languages. +description: Modern idiomatic Go (the `modernize` analyzer set) — Go 1.26+, Go 1.27 additions noted. This skill should be used when a diff or question contains a rewritable construct, when the user asks to modernize Go or run `go fix`, or asks which fixer owns a rewrite — range-over-int, `min`/`max`, `slices`/`maps`, `strings.Cut`, `any` over `interface{}`, iterators, `omitzero` json tags, `os.Root`, `new(expr)`, `errors.AsType`, dropped loop-var copies, and the Go 1.27 additions (generic methods, json/v2-backed `encoding/json`, the `atomictypes`/`embedlit`/`slicesbackward`/`unsafefuncs` fixers). Advice equals tooling — `go fix ./...` or `golangci-lint --enable-only=modernize`. Not for linter configuration (go-lint-setup). Go only. --- # go-idioms — modern Go (modernize) @@ -18,7 +18,8 @@ golangci-lint run --enable-only=modernize --fix # the x/tools modernize suit Both draw on the same `golang.org/x/tools` engine as gopls, but golangci-lint pins its own (usually newer) snapshot of it — that gap is what the **†** marker below tracks. This skill explains *why* -and catches what review notices before the tool runs. The **baseline is Go 1.26.4+**, so every row +and catches what review notices before the tool runs. The **baseline is Go 1.26.4+** (Go 1.27 is +supported too; its additions are flagged as hints in **Newer in Go 1.27** below), so every row below applies as written — the `Since` column is provenance: it explains why older code looks different, and what an older module would have to bump to before adopting the idiom. @@ -27,7 +28,10 @@ different, and what an older module would have to bump to before adopting the id The **Fixer** column names the analyzer that owns each rewrite. Plain = registered in the Go 1.26.4 toolchain's `go fix` (ground truth: `go tool fix help`; per-fixer docs: `go tool fix help `). **†** = only in the newer `x/tools` suite so far — golangci-lint's `modernize` and gopls run it, the -1.26.4 toolchain's `go fix` does not. `—` = no fixer exists: review has to catch it. +1.26.4 toolchain's `go fix` does not. `—` = no fixer exists: review has to catch it. Two † rows +below (`atomictypes`, `slicesbackward`) graduate into the stock `go fix` on 1.27, which also renames +`waitgroup` → `waitgroupgo` and drops `fmtappendf` () — see +**Newer in Go 1.27** below rather than reading that as a change to this table. | Prefer | Over | Since | Fixer | |---|---|---|---| @@ -73,17 +77,41 @@ something a modernizer rewrites. - **`slices.Sorted(maps.Keys(m))`** (1.23) when iterating a map for output — map order is random, and unstable output is a flaky-test and noisy-diff source. -*Go 1.27 (draft, expected Aug 2026) graduates several † fixers into the toolchain's `go fix` -(`atomictypes`, `slicesbackward`, plus new `embedlit` and `unsafefuncs`), renames `waitgroup` → -`waitgroupgo`, and drops `fmtappendf`; it also lands `encoding/json/v2` + `encoding/json/jsontext` -(v1 is reimplemented on v2, opt out with `GOEXPERIMENT=nojsonv2`), `strings.CutLast`/`bytes.CutLast`, -and a stdlib `uuid` package.* +*Go 1.27 (released 2026-08-19, ) graduates several † fixers into the toolchain's +`go fix` (`atomictypes`, `slicesbackward`, plus new `embedlit` and `unsafefuncs`), renames `waitgroup` +→ `waitgroupgo`, and drops `fmtappendf`; it also lands `encoding/json/v2` + `encoding/json/jsontext` +(v1 is reimplemented on v2, opt out with `GOEXPERIMENT=nojsonv2`) and `strings.CutLast`/`bytes.CutLast`. +Source: . See **Newer in Go 1.27** below for the hints these enable — none +of it is required on the 1.26 floor.* + +## Newer in Go 1.27 (hints, not requirements) + +Go 1.27 is additive over 1.26 — every 1.26 rule above still applies unchanged, and nothing here is +required while a module's `go` directive stays at 1.26. Once a repo's toolchain (and `go` directive) +moves to 1.27, these are worth reaching for. + +| Idiom (available from 1.27) | Supersedes / complements | Fixer / linter | Since | Source | +|---|---|---|---|---| +| Generic methods — a method may declare its own type parameters (interface methods still may not declare type parameters, nor be implemented by generic methods) | a package-level generic helper function bound to the receiver type as a workaround for "methods can't be generic" | — | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) | +| `encoding/json/v2` + `jsontext` when you want v2's stricter semantics (invalid UTF-8 and duplicate object names rejected) or its `Options` | v1 `encoding/json` — **keep it**: it now runs on the v2 implementation underneath, keeps its behaviour (only exact error text may shift), gets the faster unmarshal for free, and stays supported. The release notes are explicit: "users are not required to migrate". Opt out of the new backend with `GOEXPERIMENT=nojsonv2` | — | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) | +| `atomictypes` — graduates into the stock `go fix` (previously † golangci-lint-only, row above) | raw `sync/atomic` functions | `atomictypes` | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) | +| `slicesbackward` — graduates into the stock `go fix` (previously † golangci-lint-only, row above) | `for i := len(s)-1; i >= 0; i--` | `slicesbackward` | 1.27 | [go.dev/doc/go1.27](https://go.dev/doc/go1.27) | +| `embedlit` — initialise a field promoted from an embedded struct directly in the parent literal: `T{U: U{x: 1}}` → `T{x: 1}` | the nested literal an embedded struct used to require | `embedlit` | 1.27 | [modernize](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize) | +| `unsafefuncs` — `unsafe.Pointer(uintptr(ptr) + uintptr(n))` → `unsafe.Add(ptr, n)` | hand-rolled unsafe pointer arithmetic (`unsafe.Add` itself is 1.17; the fixer is new) | `unsafefuncs` | 1.27 | [modernize](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize) | + +- **`go test` runs the `stdversion` vet check by default from 1.27.** The check itself is not new — + what changes is that it becomes automatic. It flags stdlib symbols newer than the `go` directive in + force for the file, which is the guardrail that keeps a 1.26-floor module from silently depending on + a 1.27-only symbol. Trust it over manual review for this. Source: + [go.dev/doc/go1.27](https://go.dev/doc/go1.27). ## Sources - `modernize` (per-fixer docs, the Fixer column) — - `go fix` (rewritten in 1.26) — ; range-over-func — - `slog` — ; Go 1.21–1.26 release notes (`new(expr)`, self-ref generics — ) - `os.Root` / `omitzero` / `rand.Text` — ; Code Review Comments (Declaring Empty Slices, Crypto Rand) — +- Go 1.27 release notes (generic methods, `stdversion`, `encoding/json/v2`, new `go fix` modernizers) — +- `atomictypes` / `slicesbackward` / `embedlit` modernizer commits — , , --- *Decomposition inspired by [`samber/cc-skills-golang`](https://github.com/samber/cc-skills-golang) (MIT © 2026 Samuel Berthe); rules grounded in the sources above.* diff --git a/skills/go-layout/SKILL.md b/skills/go-layout/SKILL.md index 52095aa..8ae2b26 100644 --- a/skills/go-layout/SKILL.md +++ b/skills/go-layout/SKILL.md @@ -1,6 +1,6 @@ --- name: go-layout -description: Go project layout, naming, and API-surface design. This skill should be used when the user structures a Go module, names things, or shapes an exported API — `internal/`, `cmd/`, start-flat-then-grow, package/variable/receiver naming, initialism casing (`userID`, `HTTPServer`), pointer vs value receivers, in-band errors, named results, option structs vs variadic options, returning concrete types, or writing doc comments. Counters imported Java/C# structure. Not for build tooling or non-layout idioms (→ `go-idioms`). +description: Go project layout, package design and API surface. This skill should be used when the user creates a new package or directory, adds or renames an exported identifier, decides between cmd/ and internal/, writes a doc comment on an exported API, or reviews a diff that adds a package or changes a public type or signature — also util/common grab-bags, start-flat-then-grow, receiver naming, initialisms, in-band error values, and hexagonal/DDD ceremony answered with the standard-library shape. Pair with revive (var-naming, receiver-naming, exported). Not for error handling (go-errors) or tests (go-testing). --- # go-layout — layout, naming & API surface diff --git a/skills/go-lint-setup/SKILL.md b/skills/go-lint-setup/SKILL.md index 70c3015..1d4d9cd 100644 --- a/skills/go-lint-setup/SKILL.md +++ b/skills/go-lint-setup/SKILL.md @@ -1,14 +1,21 @@ --- name: go-lint-setup -description: Scaffold the reference golangci-lint v2 config into a Go repo. This skill should be used when the user runs `/go-lint-setup` or asks to "set up", "scaffold", "add", or "bootstrap" golangci-lint or a `.golangci.yml` for a Go project — it writes the plugin's reference v2 config (modernize + stack linters) and will not overwrite an existing config unprompted. For understanding what the linters do or migrating a v1 config, use `go-linting`. Not for non-Go projects. +description: Scaffold, adopt, or debug the golangci-lint v2 config in a Go repo. This skill should be used when the user runs `/go-lint-setup`, asks to "set up", "scaffold", "add" or "bootstrap" golangci-lint or a `.golangci.yml`, or asks why golangci-lint v2 rejects a config, how to migrate a v1 config, which linters the default set enables, how to adopt modernize/errorlint in an existing repo, what `golangci-lint fmt` does, how to write a `linters.exclusions` rule, or how to suppress a finding with `//nolint`. Writes the reference v2 config (modernize + stack linters); never overwrites an existing one unprompted. Go only. argument-hint: optional target path (defaults to .golangci.yml) allowed-tools: Read, Write, Glob, Bash --- -# go-lint-setup — scaffold golangci-lint v2 +# go-lint-setup — scaffold, adopt, or debug golangci-lint v2 -Scaffold the plugin's reference **golangci-lint v2** config into the current repo so its linting -matches the `go-coding` standards. Single interaction. +> **Bundled `references/` is at the plugin root** (beside `skills/`, two levels above this file) — *not* under this skill. Read `references/golangci.v2.yml` as `${CLAUDE_PLUGIN_ROOT}/references/golangci.v2.yml` on Claude Code, or `../../references/golangci.v2.yml` from this skill's directory, or Glob for the installed `references/golangci.v2.yml` (host-agnostic). + +Scaffold the plugin's reference **golangci-lint v2** config into a repo that has none, or adopt or +debug an existing config already in the repo — schema questions, migration, which linters, +`//nolint`, exclusions. Scaffolding is a single interaction. + +Asked to set up or scaffold a config → Steps 1–3 below. Asked about an existing config (rejected +keys, migration, which linters, `//nolint`, exclusions) → skip to *Adopting or debugging an +existing config*; do not write a file. Steps: @@ -21,8 +28,8 @@ Steps: 2. **Write** the config below to `.golangci.yml` (or the path given in `$ARGUMENTS`). 3. **Report how to run it:** `golangci-lint run`, and `golangci-lint run --fix` for the auto-fixable findings (`modernize` + the formatters). Suggest pinning an exact `golangci-lint` version in CI in - one place (the action's `version:` input) with an automated bump PR — see `go-linting`; don't - invent a version number here, point at the releases page. + one place (the action's `version:` input) with an automated bump PR — see *Adopting or debugging + an existing config* below; don't invent a version number here, point at the releases page. Config to write (mirrors `references/golangci.v2.yml` — keep the two in sync): @@ -50,4 +57,65 @@ formatters: - goimports ``` -For what each linter does and why, see the `go-linting` skill. +For what each linter does and why, see *Adopting or debugging an existing config* below, or the +inline comments in `references/golangci.v2.yml`. + +## Adopting or debugging an existing config + +golangci-lint **v2** (Mar 2025) changed the config schema from v1 — **a v1 config will not parse**: + +- Top-level `version: "2"` is required. +- `linters.default: standard | all | none | fast` selects the base set (no more `enable-all`). + `standard` = errcheck, govet, ineffassign, staticcheck, unused. +- **Formatters moved to their own `formatters:` section** (gofmt/gofumpt/goimports are no longer + "linters"), with their settings under `formatters.settings`. `golangci-lint fmt` runs that section. +- **Exclusions moved under `linters`**: v1's `issues.exclude-rules` → `linters.exclusions.rules`, + and `issues.exclude-dirs`/`exclude-files` → `linters.exclusions.paths`. `linters-settings` split + into `linters.settings` + `formatters.settings`. A config that still uses the old key names — + `issues:`, `linters-settings:`, `enable-all` — is v1 and needs `golangci-lint migrate` (Step 1 + above), not a hand-port; `migrate` rewrites in place, keeps a `.golangci.bck.yml` backup, and + takes `--format {yml,yaml,toml,json}` — it drops comments and unknown/deprecated keys, so re-add + comments and diff the result. + +**Common breakage when bumping the pin:** run `--fix` first, then either land the leftover findings +or add an explicit `linters.exclusions.rules` entry with a reason. If the pinned build rejects a +linter name from the reference config, the pin is too old — bump it rather than deleting the linter. + +**Adopting `modernize`:** it is the single highest-leverage linter in the reference set — it +operationalizes most `go-idioms` rules on the same engine as gopls/`go fix`, so the plugin's advice +stays consistent with the toolchain. As of **Go 1.26** the rewritten `go fix ./...` runs that same +modernizer suite from the toolchain itself; keep `modernize` in golangci-lint so CI enforces it +reproducibly against the pinned version rather than whatever toolchain a developer happens to have. +`errorlint` pairs with it in the reference config (`%w` + `errors.Is`/`AsType` discipline — see +`go-errors`). + +**Go 1.27 needs golangci-lint ≥ v2.13.0** (released 2026-08-19 — the same day as Go 1.27 itself) for +Go 1.27 support; anything v2.12.x or earlier predates it — a compatibility floor, not a pin (see +*Discipline once adopted*). Source: . + +### Discipline once adopted + +- **Pin an exact version in CI, in exactly one place — and keep the pin moving.** Upstream's own + recommendation is a specific release, not `latest`: a new release can add or retune linters and + turn every build red at once, with no code change to blame. Put the version in a single source of + truth — the `golangci/golangci-lint-action` `version:` input (it also caches, and beats a plain + binary install) or the install script's tag — never copied across several workflows and Makefiles. + Then let Renovate/Dependabot raise the bump as its own PR, so the version stays current *and* + every rule-set change arrives reviewable. This skill deliberately names no blessed version; read + the changelog for the current line. +- **Install the release binary, not from source.** Upstream states that `go install`/`go get`, the + tools pattern, and `tool` directives "aren't guaranteed to work" — they compile golangci-lint with + whatever local Go version is around. Use the binary, the action, or the Docker image, from a + release built with Go ≥ the module's toolchain (1.26+) so it can parse the language version. +- **Suppress narrowly, and say why.** `//nolint:errcheck // best-effort close on a read-only handle` + — never a bare `//nolint` (it disables every linter on that line) and never a blanket + file-level disable where a `linters.exclusions.rules` entry with a path pattern is the honest + answer. `nolintlint` enforces the specific-and-explained form. + +## Sources +- golangci-lint docs — ; v1→v2 migration guide (`migrate`, key moves) — +- v2 announcement (`fmt`, `formatters`) — +- `modernize` — + +--- +*Decomposition inspired by [`samber/cc-skills-golang`](https://github.com/samber/cc-skills-golang) (MIT © 2026 Samuel Berthe); rules grounded in the sources above.* diff --git a/skills/go-linting/SKILL.md b/skills/go-linting/SKILL.md deleted file mode 100644 index 3e9e970..0000000 --- a/skills/go-linting/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: go-linting -description: golangci-lint v2 setup and adoption for Go. This skill should be used when the user configures, upgrades, or debugs Go linting — the `.golangci.yml` file, the v2 schema (versioned config, `linters.default` set, separate formatters section, `linters.exclusions`), migrating a v1 config, `golangci-lint fmt`, suppressing a finding with `//nolint`, the `modernize` linter, or stack linters (errorlint, bodyclose, noctx, usetesting, …). Not for what individual idioms mean (use `go-idioms`) or writing rules by hand. ---- - -# go-linting — golangci-lint v2 - -> **Bundled `references/` is at the plugin root** (beside `skills/`, two levels above this file) — *not* under this skill. Read `references/golangci.v2.yml` as `${CLAUDE_PLUGIN_ROOT}/references/golangci.v2.yml` on Claude Code, or `../../references/golangci.v2.yml` from this skill's directory, or Glob for the installed `references/golangci.v2.yml` (host-agnostic). - -golangci-lint **v2** (Mar 2025) is the de-facto meta-linter and the deterministic core of this -plugin. Its schema changed from v1 — **v1 config will not parse**: - -- Top-level `version: "2"` is required. -- `linters.default: standard | all | none | fast` selects the base set (no more `enable-all`). - `standard` = errcheck, govet, ineffassign, staticcheck, unused. -- **Formatters moved to their own `formatters:` section** (gofmt/gofumpt/goimports are no longer - "linters"), with their settings under `formatters.settings`. `golangci-lint fmt` runs that section. -- **Exclusions moved under `linters`**: v1's `issues.exclude-rules` → `linters.exclusions.rules`, - and `issues.exclude-dirs`/`exclude-files` → `linters.exclusions.paths`. `linters-settings` split - into `linters.settings` + `formatters.settings`. -- **Don't hand-port a v1 config — run `golangci-lint migrate`.** It rewrites in place, keeps a - `.golangci.bck.yml` backup, and takes `--format {yml,yaml,toml,json}`. It drops comments and - unknown/deprecated keys, so re-add comments and diff the result. - -## Reference config (v2) - -```yaml -version: "2" -linters: - default: standard - enable: - - modernize # highest value: range-int, min/max, slices/maps, wg.Go, strings.Cut… - - errorlint # %w + errors.Is/As discipline - - exhaustive # a switch over an enum names every member - - bodyclose # unclosed http.Response.Body - - rowserrcheck # unchecked sql.Rows.Err - - sqlclosecheck # unclosed sql.Rows/Stmt - - noctx # HTTP/SQL without context - - contextcheck # context not propagated - - containedctx # context.Context stored in a struct - - perfsprint # fmt.Sprintf where a cheaper call exists - - usetesting # os.Setenv/os.Chdir/context.Background in tests → the t.* forms - - nolintlint # a //nolint must name a linter and carry a reason - - revive # configurable golint successor -formatters: - enable: [gofumpt, goimports] -``` - -## Adoption - -- Run: `golangci-lint run`; auto-fix what's fixable (incl. `modernize` and formatters): - `golangci-lint run --fix`. `golangci-lint fmt` applies only the `formatters:` section. -- **Pin an exact version in CI, in exactly one place — and keep the pin moving.** Upstream's own - recommendation is a specific release, not `latest`: a new release can add or retune linters and - turn every build red at once, with no code change to blame. Put the version in a single source of - truth — the `golangci/golangci-lint-action` `version:` input (it also caches, and beats a plain - binary install) or the install script's tag — never copied across several workflows and Makefiles. - Then let Renovate/Dependabot raise the bump as its own PR, so the version stays current *and* - every rule-set change arrives reviewable. This skill deliberately names no blessed version; read - the changelog for the current line. -- **Install the release binary, not from source.** Upstream states that `go install`/`go get`, the - tools pattern, and `tool` directives "aren't guaranteed to work" — they compile golangci-lint with - whatever local Go version is around. Use the binary, the action, or the Docker image, from a - release built with Go ≥ the module's toolchain (1.26+) so it can parse the language version. -- **Bumping the pin:** run `--fix` first, then either land the leftover findings or add an explicit - `linters.exclusions.rules` entry with a reason. If the pinned build rejects a linter name from the - reference config, the pin is too old — bump it rather than deleting the linter. -- **Suppress narrowly, and say why.** `//nolint:errcheck // best-effort close on a read-only handle` - — never a bare `//nolint` (it disables every linter on that line) and never a blanket - file-level disable where a `linters.exclusions.rules` entry with a path pattern is the honest - answer. `nolintlint` enforces the specific-and-explained form. -- `modernize` is the single highest-leverage linter — it operationalizes most `go-idioms` rules on - the same engine as gopls/`go fix`, so the plugin's advice stays consistent with the toolchain. As - of **Go 1.26** the rewritten `go fix ./...` runs that same modernizer suite from the toolchain - itself; keep `modernize` in golangci-lint so CI enforces it reproducibly against the pinned - version rather than whatever toolchain a developer happens to have. -- Adopt the shipped reference config `references/golangci.v2.yml`, or run `/go-lint-setup` to - scaffold it into a repo (it won't overwrite an existing config without asking). - -## Sources -- golangci-lint docs — ; v1→v2 migration guide (`migrate`, key moves) — -- v2 announcement (`fmt`, `formatters`) — -- `modernize` — - ---- -*Decomposition inspired by [`samber/cc-skills-golang`](https://github.com/samber/cc-skills-golang) (MIT © 2026 Samuel Berthe); rules grounded in the sources above.* diff --git a/skills/go-testing/SKILL.md b/skills/go-testing/SKILL.md index b71bab7..070ae5f 100644 --- a/skills/go-testing/SKILL.md +++ b/skills/go-testing/SKILL.md @@ -1,6 +1,6 @@ --- name: go-testing -description: Idiomatic Go testing. This skill should be used when the user writes or reviews Go tests, benchmarks, or fuzz targets — table-driven tests, `t.Parallel` (and what cannot run under it), `t.Context`, `t.Chdir`/`t.Setenv`, `t.TempDir` vs `t.ArtifactDir`, `t.Output`, `testing.B.Loop`, the race detector, goroutine-leak detection, `testing/synctest` for time/concurrency, fuzzing, golden files, or writing failure messages that actually diagnose. Pair with `go test -race`. Not for non-Go test frameworks; error-wrapping belongs to `go-errors`. +description: Idiomatic Go testing. This skill should be used when the user writes or reviews Go tests, benchmarks or fuzz targets — any _test.go file, table-driven `t.Run`, a can-fail control proving a guard is mutation-detectable, a refusal test asserting the operation-specific facet not a shared sentinel, `t.Parallel` (and what cannot run under it), `t.Context`, `t.Chdir`/`t.Setenv`, `t.TempDir` vs `t.ArtifactDir`, `t.Output`, `testing.B.Loop`, the race detector, goroutine-leak checks, `testing/synctest` for time and concurrency, fuzzing, golden files, or failure messages that actually diagnose. Pair with `go test -race`. Go only; error wrapping belongs to go-errors. --- # go-testing — Go testing @@ -42,13 +42,19 @@ Deterministic backstop: `go test -race ./...` (always, in CI), `go test -bench`, `synctest.Test(t, func(t *testing.T){ … })`; `synctest.Wait()` blocks until every goroutine in the bubble is durably blocked. Reach for it instead of `time.Sleep`-based polling. (Always `synctest.Test` — the pre-stable `synctest.Run` no longer exists.) - *Go 1.27 (draft, expected Aug 2026) adds `synctest.Sleep` (`time.Sleep` + `Wait` in one) and - `httptest.NewTestServer`, an in-memory server usable inside a bubble.* + *Go 1.27 (released 2026-08-19, ) adds `synctest.Sleep` (`time.Sleep` + `Wait` + in one) and `httptest.NewTestServer(t, handler)` — signature + `func NewTestServer(t testing.TB, handler http.Handler) *Server`, note the `testing.TB` first + argument that `httptest.NewServer` does not take — an in-memory server usable inside a bubble. + Sources: , .* - **Fuzzing** (`func FuzzX(f *testing.F)`) for parsers, codecs, and anything consuming untrusted bytes. **Golden files** (an `-update` flag writing `testdata/*.golden`) for large structured output. A golden pins *shape*, not behaviour — when it records something another system executes (SQL, wire requests, rendered configs), pair it with at least one test that executes the artefact for - real; a snapshot can be stable and wrong. + real; a snapshot can be stable and wrong. (Go 1.27) Never assert on compressed bytes verbatim — + `compress/flate`'s encoder changed, so `gzip`/`zip`/`zlib`/PNG output differs byte-for-byte from + 1.26 even though decompression is unaffected; compare decompressed content or a stable digest of + it instead. Source: . - **Deterministic crypto tests (Go 1.26):** `testing/cryptotest.SetGlobalRandom(t, seed)` pins a deterministic randomness source for the test's duration — reach for it instead of hand-injecting a custom `io.Reader` when testing code that draws from `crypto/rand`. It's process-global, so it