diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bef44cd3b..31dfe8fae 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -70,6 +70,19 @@ } } }, + { + "name": "go-format", + "source": "./plugins/go-format", + "category": "development", + "tags": ["go", "golang", "goimports", "formatter", "hook"], + "relevance": { + "topic": "Go", + "signals": { + "filesRead": ["**/*.go"], + "cli": ["goimports"] + } + } + }, { "name": "eol-normalizer", "displayName": "EOL Normalizer", diff --git a/README.md b/README.md index c4ae90865..1287a1fcd 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,13 @@ user opts in with `/plugin enable`; an existing install is never flipped by cata - [`biome-format`](plugins/biome-format) — Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config. - [`ruff-format`](plugins/ruff-format) — Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config. - [`typos-format`](plugins/typos-format) — Auto-fix spelling typos on edit via typos-cli, unconditionally — honoring the consuming repo's own typos configuration when one is present. +- [`go-format`](plugins/go-format) — Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files. - [`eol-normalizer`](plugins/eol-normalizer) — Normalize a written file's working-tree line endings to its .gitattributes eol value on edit — symmetric CRLF/LF driven by git check-attr, advisory and never blocking. - [`powershell-format`](plugins/powershell-format) — Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings. - [`actionlint`](plugins/actionlint) — Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context. - [`source-control`](plugins/source-control) — Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared. - [`implementation`](plugins/implementation) — Disciplined implementation stage: execute approved plans inline (`/implementation:implement`) or via orchestrated worker subagents (`/implementation:implement-dispatch`) with incremental validation, TDD-by-default cadence, green-checkpoint commits, scope-fence drift detection, and divergence detection that routes back to planning. Build/test/lint, testing, and outcome verification live in the companion `toolchain`, `testing`, and `verification` plugins, invoked when installed. -- [`toolchain`](plugins/toolchain) — Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first). +- [`toolchain`](plugins/toolchain) — Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, Go, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first). ### Testing diff --git a/docs/conventions/ecosystem-commands/examples/go.yaml b/docs/conventions/ecosystem-commands/examples/go.yaml new file mode 100644 index 000000000..4af9c0287 --- /dev/null +++ b/docs/conventions/ecosystem-commands/examples/go.yaml @@ -0,0 +1,16 @@ +# Example .claude/ecosystems/go.yaml — a consuming repo's Go command surface. +# Contract: docs/conventions/ecosystem-commands/README.md (schema: ecosystem.schema.json). +globs: ["*.go", "go.mod", "go.sum"] +project-discovery: ["go.mod"] +build-cmd: "go build ./..." +test-cmd: "go test ./..." +check-cmd: "golangci-lint run ./..." +fix-cmd: "golangci-lint run --fix ./..." +opt-in: ".golangci.yml, .golangci.yaml, .golangci.toml, or .golangci.json present (walked from the changed file up to the repo root) — otherwise golangci-lint applies its own unconfigured \"standard\" linter preset unconditionally" +install-hint: "Install golangci-lint: https://golangci-lint.run/docs/welcome/install/ | Go toolchain: https://go.dev/dl/" +gates: + - name: go-mod-tidy-drift + cmd: "go mod tidy -diff" + trigger-globs: ["go.mod", "go.sum", "*.go"] + remediation: "Run go mod tidy and commit the updated go.mod/go.sum. (go mod tidy -diff requires Go 1.23+; on an older toolchain the gate errors on the unrecognized flag rather than reporting drift.)" +notes: "govulncheck is intentionally not a rung-4 default (per the epic brief's \"optional\" framing) — add it as a consumer-local gate via .claude/ecosystems/go.local.yaml if desired." diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md index 01af5009d..c75950724 100644 --- a/docs/conventions/hook-telemetry/README.md +++ b/docs/conventions/hook-telemetry/README.md @@ -155,6 +155,7 @@ producers without coordinating with them or each other. | `markdown-format` plugin | `markdown-format` | `data/markdown-format.schema.json` | | `typos-format` plugin | `typos-format` | `data/typos-format.schema.json` | | `ruff-format` plugin | `ruff-format` | `data/ruff-format.schema.json` | +| `go-format` plugin | `go-format` | `data/go-format.schema.json` | | `bash-format` plugin | `bash-format` | `data/bash-format.schema.json` | | `desktop-notification` plugin | `desktop-notification` | `data/desktop-notification.schema.json` | | `guardrails` plugin | `secret-pattern-detection` | `data/secret-pattern-detection.schema.json` | diff --git a/docs/conventions/hook-telemetry/data/go-format.schema.json b/docs/conventions/hook-telemetry/data/go-format.schema.json new file mode 100644 index 000000000..850e88a17 --- /dev/null +++ b/docs/conventions/hook-telemetry/data/go-format.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/go-format.schema.json", + "title": "go-format telemetry data", + "description": "Per-hook `data` payload for the go-format hook. Discovered from the envelope `hook` value \"go-format\". Evolves additive-only.", + "type": "object", + "required": ["tool", "file", "findings"], + "additionalProperties": true, + "properties": { + "tool": { + "type": "string", + "description": "Claude Code tool that triggered the hook (Write or Edit)." + }, + "file": { + "type": "string", + "description": "Path of the formatted Go file, relative to the consuming repo root." + }, + "findings": { + "type": "array", + "items": { "type": "string" }, + "description": "goimports syntax-error diagnostic lines when the file could not be parsed, one line per diagnostic. Empty array = clean or successfully autofixed (a successful format/import fix carries no findings)." + } + } +} diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json new file mode 100644 index 000000000..3e090668e --- /dev/null +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "go-format", + "version": "0.1.0", + "description": "Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.", + "author": { + "name": "Melodic Software", + "email": "info@melodicsoftware.com" + }, + "license": "MIT", + "keywords": [ + "go", + "golang", + "goimports", + "formatter", + "hook" + ], + "userConfig": { + "go_format_enabled": { + "type": "boolean", + "title": "go-format hook", + "description": "Run goimports -w on edit of a Go file", + "default": true + } + } +} diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md new file mode 100644 index 000000000..cdb3dde63 --- /dev/null +++ b/plugins/go-format/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to the `go-format` plugin are documented here. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. + +## [0.1.0] + +### Added + +- Initial release: a `PostToolUse` hook that runs `goimports -w` on + `Write`/`Edit` of a `.go` file — unconditionally, with no consumer-config + opt-in gate (the one deliberate shape difference from the + `ruff-format`/`typos-format` pattern; see issue #832's field survey). + Skips files carrying Go's `// Code generated ... DO NOT EDIT.` marker. + Syntax errors goimports can't parse surface via `additionalContext` as an + advisory finding, never a tool break. Advisory only — never blocks the + edit. +- `hook-telemetry` conformance: emits a schema-valid envelope + (`docs/conventions/hook-telemetry/data/go-format.schema.json`) via the + shared `hook::emit_telemetry` helper. +- `/go-format:setup check|apply` skill for prerequisite verification. diff --git a/plugins/go-format/README.md b/plugins/go-format/README.md new file mode 100644 index 000000000..15c84d3f6 --- /dev/null +++ b/plugins/go-format/README.md @@ -0,0 +1,101 @@ +# go-format + +A Claude Code plugin that formats Go files and manages their imports the +moment you edit them. On every `Write` or `Edit` of a `.go` file it runs +[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports)'s `-w`, +which adds missing imports, removes unused ones, and applies `gofmt`- +equivalent formatting — then surfaces any syntax error goimports can't parse +back to Claude as advisory context. + +## Behavior + +- **Unconditional — no consumer-config opt-in gate.** Unlike sibling + formatter plugins (`ruff-format`, `typos-format`), this hook runs on every + edited `.go` file regardless of repository configuration. `goimports`' + own docs describe it as "a replacement for your editor's gofmt-on-save + hook" and it has no meaningful config-divergence axis when left + unconfigured — running it does not impose a style choice a repo hasn't + made, the same reasoning that makes `gofmt` itself safe to run + unconditionally. +- **Extension-scoped.** Only `.go` files trigger the hook (like + `ruff-format`'s `*.py`/`*.pyi` filter; unlike `typos-format`'s + language-agnostic scope). +- **Skips generated files.** A file whose leading comment/blank-line block + contains Go's canonical `// Code generated ... DO NOT EDIT.` marker is + left untouched — this includes files where a copyright/license header + (a `//` or `/* */` block) precedes the marker, common for + `addlicense`/`goheader` output. `goimports` itself has no awareness of + that convention, so this hook adds the guard itself. +- **Fix in place.** Formatting and import changes are applied silently — no + advisory noise on a successful fix, the same posture as a successful + `ruff-format`/`typos-format` autofix pass. +- **Groups local imports using your module's own path.** When a `go` + toolchain is on `PATH`, the hook resolves the edited file's own module + path (`go list -m`) and passes it as goimports' `-local` grouping prefix, + so your package's own internal imports stay in their own group instead of + being collapsed into the third-party group — matching goimports' own + `-local` convention without adding any new consumer config. Falls back to + goimports' plain default grouping when `go` is absent or the file isn't + in a resolvable module. +- **Syntax errors surface as advisory findings.** When `goimports` can't + parse the file, the parse diagnostic is reported via `additionalContext`, + never auto-"fixed" and never treated as a tool break. +- **Advisory, never blocking.** The hook always exits `0`. Findings are + reported via `additionalContext`; they never reject the edit. Make a + commit hook or CI your hard gate. + +## Requirements + +- **Bash** — the hook is a Bash script. On native Windows, install + [Git for Windows](https://code.claude.com/docs/en/setup#set-up-on-windows) so + Claude Code can run it under Git Bash. +- **jq** on `PATH` — parses the hook payload. Absent: the hook skips with a + visible once-per-session notice. [Install jq](https://jqlang.org/download/). +- **goimports** on `PATH`. Like `typos-format`, `goimports` has no + per-repo dependency-manager convention — it is conventionally + `go install`ed to the machine-global `$GOPATH/bin`. It is never + downloaded on the fly; if it is not present, the hook skips with a + visible once-per-session notice. + [Install](https://pkg.go.dev/golang.org/x/tools/cmd/goimports): + `go install golang.org/x/tools/cmd/goimports@latest` (requires a + [Go toolchain](https://go.dev/dl/)). +- **`go` on `PATH` (optional).** Used only to resolve the `-local` grouping + prefix (`go list -m`). Absent: the hook still formats/fixes imports, just + without the `-local` grouping (goimports' plain default behavior). + +The hook itself runs on Bash 3.2+. Telemetry timing uses `EPOCHREALTIME` +(Bash 5.0+); on older bash the telemetry envelope is skipped while +formatting still runs. + +## Install + +```shell +/plugin marketplace add melodic-software/claude-code-plugins +/plugin install go-format@melodic-software +``` + +Then verify prerequisites with `/go-format:setup check`. + +## Configuration + +There are no rules to configure — `goimports` runs with no consumer-config +surface to read. One `userConfig` option tunes the hook itself: + +| Option | Default | Effect | +|--------|---------|--------| +| `go_format_enabled` | `true` | Kill switch — set `false` for a clean no-op. | + +Set it interactively with `/plugin configure go-format`, or headless on the +install command: + +```shell +claude plugin install go-format@melodic-software --config go_format_enabled=false +``` + +These options are user-scoped (stored in your user settings, not the +project's). To turn the plugin off for a single repository, disable it in +that project's `enabledPlugins` instead. + +## License + +MIT (SPDX-License-Identifier: MIT). diff --git a/plugins/go-format/hooks/go-format.sh b/plugins/go-format/hooks/go-format.sh new file mode 100755 index 000000000..3251bc84c --- /dev/null +++ b/plugins/go-format/hooks/go-format.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# PostToolUse hook: auto-format and manage imports for Go files via goimports. +# Triggered on Write|Edit of *.go files. +# +# ADVISORY: always exits 0. `goimports -w` rewrites the file in place; +# a residual syntax error goimports cannot parse surfaces via +# additionalContext but never blocks the edit. A commit hook or CI is the +# hard gate. +# +# UNCONDITIONAL — no consumer-config opt-in gate, unlike the sibling +# ruff-format/typos-format/dotnet ecosystem entry. goimports' own docs state +# it "formats your code in the same style as gofmt so it can be used as a +# replacement for your editor's gofmt-on-save hook" — an explicit official +# statement of intent for exactly this per-file/on-save scenario, and it has +# no meaningful config-divergence axis when left unconfigured (unlike +# ruff/dotnet format, whose underlying tools DO have configurable, genuinely +# divergent output). +# +# GENERATED-FILE GUARD: goimports has zero awareness of Go's own +# `// Code generated ... DO NOT EDIT.` convention — empirically confirmed it +# rewrites such files with no warning. Generated Go files (protobuf, mockgen, +# sqlc, stringer, wire output) are common; this hook skips any file whose +# leading comment/blank-line block (scanned below) contains that marker, +# mirroring the precision `--force-exclude` gives Ruff/typos for free via +# consumer config (this hook has no config to consult, so the marker check +# is the equivalent guard). +# +# The goimports binary is resolved from PATH only — never downloaded. Go +# binaries are conventionally `go install`ed to $GOPATH/bin, which a +# developer adds to PATH themselves; there is no per-project virtualenv +# concept in Go the way ruff-format walks a .venv. + +set -uo pipefail + +# Read inherited fd0 directly (bare cat) — NEVER ` silent no-op). stdin is read ONCE here and fed to both +# hook::read_file_path (file_path) and the tool_name parse below; reading fd0 +# twice would drain the pipe on the second call. +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" + +hook::check_enabled "GO_FORMAT" + +# Capture $EPOCHREALTIME immediately after kill-switch so duration_ms covers the +# work below (pre-work exits do not emit telemetry). EPOCHREALTIME is Bash 5.0+; +# on older bash it is unset, so default to empty — referencing it bare under +# `set -u` would abort before the advisory exit 0, failing every edit. +start=${EPOCHREALTIME:-} + +# Telemetry needs the high-res start stamp. When EPOCHREALTIME is unavailable +# (Bash < 5.0) the stamp is empty and telemetry is skipped, so the hook still +# formats on older bash rather than aborting. +emit_tel() { + [[ -n "$start" ]] || return 0 + hook::emit_telemetry "$@" +} + +INPUT=$(hook::buffer_stdin) || exit 0 + +# jq-free applicability pre-filter: never emit the jq notice for an edit this +# hook would not process anyway (the Write|Edit matcher is broader than the +# Go-file filter). +RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0 +case "$RAW_FILE" in +*.go) ;; +*) exit 0 ;; +esac + +# jq is load-bearing for input parsing; absent → visible once-per-session skip +# notice instead of a silent no-op (dim-9 doctrine). +hook::require_jq PostToolUse go-format "$INPUT" + +FILE=$(printf '%s' "$INPUT" | hook::read_file_path) || exit 0 +case "$FILE" in +*.go) ;; +*) exit 0 ;; +esac + +TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null) + +# Resolve repo root early — used to compute the schema-required repo-relative +# path in data.file. +REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")" +# Repo-relative path: schema requires "relative to the consuming repo root". +# On Windows Git Bash, git rev-parse --show-toplevel returns a drive-letter path +# while FILE may be in POSIX mount form. Normalize both through cygpath -lm +# (long name, forward-slash mixed form) when available so the prefix strip +# compares the same representation. On Linux/macOS, cygpath is absent and both +# paths are already POSIX. Falls back to raw FILE on any normalization error. +FILE_REL="$FILE" +if command -v cygpath >/dev/null 2>&1; then + _file_lm=$(cygpath -lm "$FILE" 2>/dev/null) + _root_lm=$(cygpath -lm "$REPO_ROOT" 2>/dev/null) + if [[ -n "$_file_lm" && -n "$_root_lm" ]]; then + FILE_REL="${_file_lm#"$_root_lm"/}" + fi +else + FILE_REL="${FILE#"$REPO_ROOT"/}" +fi + +# Build the telemetry data object for the current TOOL/FILE_REL. $1 is the +# findings JSON array. jq is authoritative. The fallback is a fixed empty-shape +# object — NOT an interpolation of TOOL/FILE_REL, which could inject quotes or +# backslashes from a path and corrupt the envelope. +build_data_json() { + jq -n \ + --arg tool "$TOOL" \ + --arg file "$FILE_REL" \ + --argjson findings "$1" \ + '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null || + printf '{"tool":"","file":"","findings":[]}' +} + +emit_skipped() { + local data_json + data_json=$(build_data_json '[]') + emit_tel "go-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT" + exit 0 +} + +# Generated-file guard: skip files carrying Go's canonical generated-code +# marker — goimports itself has no awareness of this convention (empirically +# confirmed it rewrites such files silently). Go's own convention (`go help +# generate`, verified live: "This line must appear before the first +# non-comment, non-blank text in the file") does not restrict "comment" to +# `//` style — a `/* ... */` block comment (e.g. a conventional block +# license header) is a comment for this purpose too, so it must not end the +# leading-block scan early. Scan the file's leading comment/blank-line run, +# tracking open `/* */` blocks, and stop only at the first line that is +# genuinely neither blank, a `//` comment, nor inside/starting a `/* */` +# block (e.g. `package foo`). A trailing CRLF `\r` and a leading UTF-8 BOM +# are stripped per line so a Windows-checked-out or BOM-prefixed file still +# matches. (The marker itself can only ever appear on a `//`-prefixed line — +# `^// Code generated .* DO NOT EDIT\.$` — never inside a `/* */` block, so +# block-comment lines are only ever scanned-through, not matched against.) +GENERATED=0 +IN_BLOCK=0 +while IFS= read -r _line || [[ -n "$_line" ]]; do + _line="${_line%$'\r'}" + _line="${_line#$'\xEF\xBB\xBF'}" + if [[ $IN_BLOCK -eq 1 ]]; then + [[ "$_line" == *'*/'* ]] && IN_BLOCK=0 + continue # still inside (or just closed) a block comment: keep scanning + fi + [[ -n "${_line//[[:space:]]/}" ]] || continue # blank line (any whitespace, incl. tabs): keep scanning the leading block + # Trimmed only for comment-shape classification (an indented `//`/`/*` + # still counts as "still within the leading comment block") — the marker + # regex itself stays column-0-anchored, matching Go's own convention. + _trimmed="${_line#"${_line%%[![:space:]]*}"}" + if [[ "$_line" == //* ]]; then + if [[ "$_line" =~ ^//\ Code\ generated\ .*\ DO\ NOT\ EDIT\.$ ]]; then + GENERATED=1 + fi + [[ $GENERATED -eq 1 ]] && break + continue # a different // comment line: still within the leading block + fi + if [[ "$_trimmed" == //* ]]; then + continue # an indented // comment line: still within the leading block + fi + if [[ "$_trimmed" == /\** ]]; then + [[ "$_trimmed" == *'*/'* ]] || IN_BLOCK=1 # opens a block comment spanning further lines + continue # an indented /* ... */ comment (single- or multi-line): still within the leading block + fi + break # first non-comment, non-blank line: leading block ended, marker absent +done <"$FILE" +[[ $GENERATED -eq 1 ]] && emit_skipped + +# Resolve the goimports binary from PATH — never downloaded. +GOIMPORTS_BIN="$(command -v goimports 2>/dev/null)" || GOIMPORTS_BIN="" + +if [[ -z "$GOIMPORTS_BIN" ]]; then + if hook::notice_once "go-format-goimports" "$INPUT"; then + hook::emit_skip_notice PostToolUse "go-format: no 'goimports' binary found on PATH — format/import-fix skipped for this session. Install: go install golang.org/x/tools/cmd/goimports@latest" + fi + emit_skipped +fi + +# Auto-derive goimports' -local grouping prefix from the edited file's own +# module path (`go list -m`, which walks up to the nearest go.mod using +# Go's own module resolution — more robust than hand-parsing the module +# directive). Without -local, goimports lumps a repo's own internal +# packages into the same group as third-party imports; a repo that already +# formats with -local (a common Go convention, e.g. wired into its own CI +# or editor config) would have this hook re-collapse that grouping on every +# edit — empirically confirmed this materially changes output when a +# third-party import is also present. Deriving the LOCAL prefix from the +# file's own module path (self-grouping) requires no new consumer-config +# surface, so it stays within the unconditional/no-opt-in design while +# covering the single most common -local use case. `go` absent, the file +# outside any module, or any other resolution failure all degrade to no +# -local flag (goimports' plain default grouping), never a hard stop. +LOCAL_PREFIX="" +if command -v go >/dev/null 2>&1; then + LOCAL_PREFIX="$(cd "$(dirname "$FILE")" 2>/dev/null && go list -m 2>/dev/null)" || LOCAL_PREFIX="" + [[ "$LOCAL_PREFIX" == "command-line-arguments" ]] && LOCAL_PREFIX="" +fi +GOIMPORTS_ARGS=(-w -l) +[[ -n "$LOCAL_PREFIX" ]] && GOIMPORTS_ARGS+=(-local "$LOCAL_PREFIX") + +# -w writes the fix in place; -l (combined with -w) lists the changed +# filename on stdout, which this hook doesn't need (a successful autofix +# carries no advisory noise, same posture as a successful ruff/typos fix +# pass — only a genuine syntax error below produces a finding). Verified +# empirically (goimports v0.48.0): -l ALWAYS exits 0, even when it lists a +# file — there is no exit-1-style "findings" signal like ruff/typos have. +# Non-zero exit (verified: 2, with a parseable message on stderr) occurs +# only on a genuine parse/syntax error — captured via command substitution +# (stdout discarded, stderr redirected to fd1) the same way every sibling +# hook in this repo captures tool output, rather than a temp file. `--` +# ends flag parsing before $FILE — defense-in-depth against a path that +# happens to start with `-` being misread as a flag by Go's flag package. +STDERR=$("$GOIMPORTS_BIN" "${GOIMPORTS_ARGS[@]}" -- "$FILE" 2>&1 >/dev/null) +RC=$? + +if [[ $RC -eq 0 ]]; then + # Clean, or fixed silently (formatting/import changes carry no advisory + # noise — same posture as a successful ruff/typos autofix pass). + data_json=$(build_data_json '[]') + emit_tel "go-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" + exit 0 +fi + +if [[ $RC -eq 2 && -n "$STDERR" ]]; then + # goimports ran and produced a judgment: the file has a syntax error it + # cannot parse. This is a finding, not a tool break — mirrors how + # ruff-format surfaces a mid-edit syntax error as a finding. + hook::ctx_reset + hook::ctx_append "go-format: $(basename "$FILE") has a syntax error goimports could not parse (advisory):" + findings_raw="" + while IFS= read -r line; do + [[ -n "$line" ]] || continue + hook::ctx_append " $line" + findings_raw+="$line"$'\n' + done <<<"$STDERR" + hook::ctx_flush PostToolUse + + FINDINGS_JSON='[]' + if [[ -n "$findings_raw" ]]; then + FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]' + fi + data_json=$(build_data_json "$FINDINGS_JSON") + emit_tel "go-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT" + exit 0 +fi + +# goimports broke for non-syntax reasons (internal error, unexpected exit +# code) — no judgment was made. Surface the diagnostic via additionalContext +# (NOT stderr — an advisory hook's exit-0 stderr can trip a false "Hook +# Error" label). Record as "skipped" (the tool never ran to judgment). +hook::ctx_reset +hook::ctx_append "go-format: goimports failed for $(basename "$FILE") (no diagnostics; tool break, not a finding):" +while IFS= read -r line; do + [[ -n "$line" ]] || continue + hook::ctx_append " $line" +done <<<"$STDERR" +hook::ctx_flush PostToolUse +data_json=$(build_data_json '[]') +emit_tel "go-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT" +exit 0 diff --git a/plugins/go-format/hooks/go-format.test.sh b/plugins/go-format/hooks/go-format.test.sh new file mode 100755 index 000000000..dce6a824b --- /dev/null +++ b/plugins/go-format/hooks/go-format.test.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# Black-box contract test for go-format.sh (the go-format plugin hook). +# +# Proves WIRING: the hook fires only on *.go files (extension pre-filter), +# runs goimports UNCONDITIONALLY (no consumer-config opt-in gate — the one +# deliberate shape difference from ruff-format/typos-format; see +# docs/topics/832-go-ecosystem/PLAN.md Open Decision 1), skips files carrying +# Go's generated-code marker, autofixes imports/formatting in place, surfaces +# a syntax error as an advisory finding (not a tool break), honors the kill +# switch, and emits a schema-valid telemetry envelope. +# +# Self-contained: builds throwaway git repos with runtime-generated fixtures. +# The hook is invoked from an UNRELATED cwd so any reliance on the caller's +# own working directory would surface. +# +# Requires a real goimports binary: $GOIMPORTS_TEST_BIN if set, else +# `goimports` on PATH. Without one the behavioral assertions cannot run, so +# the suite skips. + +set -uo pipefail + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOK="$HOOK_DIR/go-format.sh" + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $*" >&2 + FAIL=$((FAIL + 1)) +} +ok() { + echo "ok: $*" + PASS=$((PASS + 1)) +} + +# Resolve a real goimports binary. Skip the suite when none is available. +if [[ -n "${GOIMPORTS_TEST_BIN:-}" && -x "${GOIMPORTS_TEST_BIN}" ]]; then + REAL_GOIMPORTS="${GOIMPORTS_TEST_BIN}" +elif command -v goimports >/dev/null 2>&1; then + REAL_GOIMPORTS="$(command -v goimports)" +else + echo "SKIP: no goimports binary (set GOIMPORTS_TEST_BIN or put goimports on PATH) -- go-format hook tests skipped" + exit 0 +fi + +WORK="$(mktemp -d)" +UNRELATED="$(mktemp -d)" +cleanup() { rm -rf "$WORK" "$UNRELATED"; } +trap cleanup EXIT + +# make_sink -> path to an executable single-command stub sink running +# (which reads the envelope on stdin). HOOK_TELEMETRY_SINK must be a +# single executable path, not a command-with-args, so tests point it at a stub. +make_sink() { + local s + s="$(mktemp -p "$WORK" sink.XXXXXX)" + { + printf '#!/usr/bin/env bash\n' + printf '%s\n' "$1" + } >"$s" + chmod +x "$s" + printf '%s' "$s" +} + +# wait_for_sink [tries] -> block until is non-empty (the +# fire-and-forget sink flushed) or the bound elapses, polling in 20ms steps. +wait_for_sink() { + local f="$1" tries="${2:-150}" + while ((tries-- > 0)); do + if [[ -s "$f" ]]; then + return 0 + fi + sleep 0.02 + done + return 1 +} + +new_go_repo() { + local r="$1" + mkdir -p "$r" + git -C "$r" init -q + git -C "$r" config user.email t@t.t + git -C "$r" config user.name t +} + +# Invoke the hook from an unrelated cwd. CLAUDE_PROJECT_DIR is left UNSET so +# read_file_path's membership guard is disabled (not part of the fire gate). +run_hook() { + local file_path="$1" + ( + cd "$UNRELATED" || return 1 + printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$file_path" | + env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" bash "$HOOK" + ) +} + +# Same as run_hook but with caller-supplied extra env (NAME=VALUE ...). +run_hook_env() { + local file_path="$1" + shift + ( + cd "$UNRELATED" || return 1 + printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$file_path" | + env -u CLAUDE_PROJECT_DIR "$@" bash "$HOOK" + ) +} + +REPO="$WORK/consumer" +new_go_repo "$REPO" + +# --- Case 1: unconditional (no config anywhere) -> still runs --------------- +# Unlike ruff-format/typos-format, go-format has NO consumer-config opt-in +# gate — it must autofix even with zero Go-specific config present. +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/needs_import.go" +OUT=$(run_hook "$REPO/needs_import.go") +RC=$? +if [[ $RC -eq 0 ]]; then ok "no config anywhere -> exit 0 (unconditional)"; else fail "no-config exit $RC"; fi +if grep -q 'import "fmt"' "$REPO/needs_import.go"; then + ok "no config anywhere -> import still added (unconditional, no opt-in gate)" +else + fail "no-config -> import not added: $(cat "$REPO/needs_import.go")" +fi + +# --- Case 2: clean file -> exit 0, empty stdout ------------------------------ +printf 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/clean.go" +OUT=$(run_hook "$REPO/clean.go") +RC=$? +if [[ $RC -eq 0 ]]; then ok "clean file -> exit 0"; else fail "clean file exit $RC"; fi +if [[ -z "$OUT" ]]; then ok "clean file -> empty stdout"; else fail "clean file stdout not empty: $OUT"; fi + +# --- Case 3: import needed by edit -> auto-added in a subdir ----------------- +mkdir -p "$REPO/src" +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/src/fix.go" +OUT=$(run_hook "$REPO/src/fix.go") +RC=$? +if [[ $RC -eq 0 ]]; then ok "missing import (subdir) -> exit 0 (advisory)"; else fail "missing import exit $RC"; fi +if grep -q 'import "fmt"' "$REPO/src/fix.go"; then + ok "missing import (subdir) -> auto-added" +else + fail "missing import -> not added: $(cat "$REPO/src/fix.go")" +fi + +# --- Case 4: unused import -> auto-removed ----------------------------------- +printf 'package main\n\nimport (\n\t"fmt"\n\t"os"\n)\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/unused.go" +run_hook "$REPO/unused.go" >/dev/null +if ! grep -q '"os"' "$REPO/unused.go"; then + ok "unused import -> auto-removed" +else + fail "unused import -> still present: $(cat "$REPO/unused.go")" +fi + +# --- Case 4b: -local grouping auto-derived from the file's own go.mod ------- +# Without -local, goimports lumps local (in-module) imports into the same +# group as third-party imports. The hook should derive -local from the +# edited file's own module path (`go list -m`) so a repo's own internal +# packages get their own group, matching goimports' documented -local +# behavior — requires a real `go` binary on PATH (skip this case if absent, +# same posture as the goimports-binary requirement for the whole suite). +if command -v go >/dev/null 2>&1; then + REPO_LOCAL="$WORK/local-grouping" + new_go_repo "$REPO_LOCAL" + printf 'module example.com/localtest\n\ngo 1.23\n' >"$REPO_LOCAL/go.mod" + mkdir -p "$REPO_LOCAL/internal/pkg" + printf 'package pkg\n\nfunc Hello() string { return "hi" }\n' >"$REPO_LOCAL/internal/pkg/pkg.go" + # A third-party-shaped (unresolved, never fetched) import is required to + # observe -local's effect: with only stdlib + local imports present, + # goimports' output is IDENTICAL with or without -local (empirically + # confirmed) — -local's job is separating LOCAL from THIRD-PARTY, and + # goimports can reorder an already-present import without resolving it. + printf 'package main\n\nimport (\n\t"example.com/localtest/internal/pkg"\n\t"fmt"\n\t"github.com/pkg/errors"\n)\n\nfunc main() {\n\tfmt.Println(pkg.Hello())\n\t_ = errors.New("x")\n}\n' >"$REPO_LOCAL/main.go" + run_hook "$REPO_LOCAL/main.go" >/dev/null + # With -local applied, the local import gets its own trailing group, + # separated from the third-party group by a blank line (goimports' + # un-grouped default would instead lump local + third-party into one + # sorted block after stdlib). + if grep -qF $'"github.com/pkg/errors"\n\n\t"example.com/localtest/internal/pkg"' "$REPO_LOCAL/main.go"; then + ok "-local auto-derived from go.mod -> local import grouped separately from third-party" + else + fail "-local grouping not applied: $(cat "$REPO_LOCAL/main.go")" + fi +else + echo "SKIP: no go binary on PATH -- -local grouping case (4b) skipped" +fi + +# --- Case 5: generated-file marker -> skipped, left untouched --------------- +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated.go" +BEFORE_GEN="$(cat "$REPO/generated.go")" +OUT=$(run_hook "$REPO/generated.go") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "generated-marker file -> exit 0, silent"; else fail "generated-marker not silent (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO/generated.go")" == "$BEFORE_GEN" ]]; then + ok "generated-marker file -> left untouched (import NOT added despite being missing)" +else + fail "generated-marker file -> was rewritten: $(cat "$REPO/generated.go")" +fi + +# --- Case 5b: marker preceded by a license-header comment block ------------- +# Common real-world shape (addlicense/goheader-style tooling prepends a +# copyright header before the generated-code marker) — the marker is NOT on +# the file's first non-blank line. The guard must still catch it. +printf '// Copyright 2026 Example Corp. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license that can be found in the LICENSE file.\n\n// Code generated by "stringer -type Op"; DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-header.go" +BEFORE_GH="$(cat "$REPO/generated-header.go")" +run_hook "$REPO/generated-header.go" >/dev/null +if [[ "$(cat "$REPO/generated-header.go")" == "$BEFORE_GH" ]]; then + ok "generated-marker behind a license-header block -> still caught, left untouched" +else + fail "generated-marker behind a license-header block -> was rewritten: $(cat "$REPO/generated-header.go")" +fi + +# --- Case 5c: marker line has a trailing CRLF \r ----------------------------- +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\r\npackage main\r\n\r\nfunc main() {\r\n\tfmt.Println("hi")\r\n}\r\n' >"$REPO/generated-crlf.go" +BEFORE_CRLF="$(cat "$REPO/generated-crlf.go")" +run_hook "$REPO/generated-crlf.go" >/dev/null +if [[ "$(cat "$REPO/generated-crlf.go")" == "$BEFORE_CRLF" ]]; then + ok "generated-marker with CRLF line endings -> still caught, left untouched" +else + fail "generated-marker with CRLF line endings -> was rewritten" +fi + +# --- Case 5d: UTF-8 BOM before the marker on the first line ----------------- +printf '\xEF\xBB\xBF// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-bom.go" +BEFORE_BOM="$(cat "$REPO/generated-bom.go")" +run_hook "$REPO/generated-bom.go" >/dev/null +if [[ "$(cat "$REPO/generated-bom.go")" == "$BEFORE_BOM" ]]; then + ok "generated-marker with a leading UTF-8 BOM -> still caught, left untouched" +else + fail "generated-marker with a leading UTF-8 BOM -> was rewritten" +fi + +# --- Case 5e: marker AFTER the leading comment/blank block -> NOT generated - +# The marker must appear before the first non-comment, non-blank line to +# count — a file that merely mentions the marker text after `package main` +# has already started is real (or at least not-provably-generated) code and +# must still be formatted normally. +printf 'package main\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/not-generated.go" +run_hook "$REPO/not-generated.go" >/dev/null +if grep -q 'import "fmt"' "$REPO/not-generated.go"; then + ok "marker after leading block -> not treated as generated, import still added" +else + fail "marker after leading block -> wrongly treated as generated: $(cat "$REPO/not-generated.go")" +fi + +# --- Case 5f: marker preceded by a /* */ block-comment license header ------- +# Go's own convention ("go help generate": the marker "must appear before +# the first non-comment, non-blank text in the file") does not restrict +# "comment" to `//` style — a block-comment license header must not defeat +# the guard either. +printf '/*\nCopyright 2026 Example Corp. All rights reserved.\n*/\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-block-header.go" +BEFORE_BLOCK="$(cat "$REPO/generated-block-header.go")" +run_hook "$REPO/generated-block-header.go" >/dev/null +if [[ "$(cat "$REPO/generated-block-header.go")" == "$BEFORE_BLOCK" ]]; then + ok "generated-marker behind a /* */ block-comment header -> still caught, left untouched" +else + fail "generated-marker behind a /* */ block-comment header -> was rewritten: $(cat "$REPO/generated-block-header.go")" +fi + +# --- Case 5g: INDENTED /* */ block-comment header before the marker --------- +# go/ast.IsGenerated classifies a file as generated regardless of +# block-comment indentation; comment-shape classification must tolerate +# leading whitespace even though the marker regex itself stays +# column-0-anchored (matching Go's own convention exactly). +printf ' /*\n Copyright 2026 Example Corp. All rights reserved.\n */\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-indented-block-header.go" +BEFORE_INDENT_BLOCK="$(cat "$REPO/generated-indented-block-header.go")" +run_hook "$REPO/generated-indented-block-header.go" >/dev/null +if [[ "$(cat "$REPO/generated-indented-block-header.go")" == "$BEFORE_INDENT_BLOCK" ]]; then + ok "generated-marker behind an INDENTED /* */ block-comment header -> still caught, left untouched" +else + fail "generated-marker behind an indented /* */ block-comment header -> was rewritten: $(cat "$REPO/generated-indented-block-header.go")" +fi + +# --- Case 5h: TAB-only separator line before the marker --------------------- +# The blank-line check must treat any horizontal whitespace as blank, not +# just literal spaces — a tab-only line between a block-comment header and +# the marker must not be misread as non-comment content. +printf '/*\nCopyright.\n*/\n\t\n// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-tab-blank.go" +BEFORE_TAB_BLANK="$(cat "$REPO/generated-tab-blank.go")" +run_hook "$REPO/generated-tab-blank.go" >/dev/null +if [[ "$(cat "$REPO/generated-tab-blank.go")" == "$BEFORE_TAB_BLANK" ]]; then + ok "generated-marker with a TAB-only separator line -> still caught, left untouched" +else + fail "generated-marker with a tab-only separator line -> was rewritten: $(cat "$REPO/generated-tab-blank.go")" +fi + +# --- Case 6: non-.go extension -> hook does not fire ------------------------- +printf 'this is not go' >"$REPO/notes.txt" +BEFORE_TXT="$(cat "$REPO/notes.txt")" +OUT=$(run_hook "$REPO/notes.txt") +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "non-.go file -> exit 0, silent"; else fail "non-.go file not silent (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO/notes.txt")" == "$BEFORE_TXT" ]]; then ok "non-.go file -> untouched"; else fail "non-.go file -> was modified"; fi + +# --- Case 7: syntax error -> surfaced as advisory finding, not a tool break -- +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi"\n}\n' >"$REPO/syntax.go" +OUT=$(run_hook "$REPO/syntax.go") +RC=$? +if [[ $RC -eq 0 ]]; then ok "syntax error -> exit 0 (advisory)"; else fail "syntax error exit $RC (must be advisory)"; fi +if printf '%s' "$OUT" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then + CTX=$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.additionalContext') + if printf '%s' "$CTX" | grep -qi 'syntax error'; then + ok "syntax error -> surfaced in additionalContext as a finding" + else + fail "syntax error ctx wrong shape: $CTX" + fi +else + fail "syntax error -> no additionalContext JSON: $OUT" +fi + +# --- Case 8: kill switch bypasses hook --------------------------------------- +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/kill.go" +BEFORE_K="$(cat "$REPO/kill.go")" +OUT=$(run_hook_env "$REPO/kill.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=false) +RC=$? +if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "kill switch off -> exit 0 silent"; else fail "kill switch failed (rc=$RC out=$OUT)"; fi +if [[ "$(cat "$REPO/kill.go")" == "$BEFORE_K" ]]; then ok "kill switch -> file untouched"; else fail "kill switch -> file was modified"; fi + +# ============================================================================ +# Telemetry +# ============================================================================ + +# --- Sink unset -> empty stdout, exit 0 (parity) ------------------------------ +printf 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/tel-clean.go" +OUT_NS=$(run_hook_env "$REPO/tel-clean.go" -u HOOK_TELEMETRY_SINK PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true) +RC_NS=$? +if [[ $RC_NS -eq 0 && -z "$OUT_NS" ]]; then + ok "telemetry/sink-unset: exit 0, empty stdout (parity)" +else + fail "telemetry/sink-unset: rc=$RC_NS out=$OUT_NS" +fi + +# --- Stub sink + syntax-error finding -> envelope status ok with findings --- +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi"\n}\n' >"$REPO/tel.go" +TEL="$(mktemp)" +SINK="$(make_sink "cat >\"$TEL\"")" +run_hook_env "$REPO/tel.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINK" >/dev/null +wait_for_sink "$TEL" +if [[ -s "$TEL" ]]; then + ok "telemetry/stub-sink: envelope received" + for field in schema_version timestamp hook hook_event status duration_ms data; do + if jq -e "has(\"$field\")" "$TEL" >/dev/null 2>&1; then + ok "envelope: $field present" + else + fail "envelope: $field missing ($(cat "$TEL"))" + fi + done + if [[ "$(jq -r '.hook' "$TEL")" == "go-format" ]]; then ok "envelope: hook is go-format"; else fail "envelope: hook=$(jq -r '.hook' "$TEL")"; fi + if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi + if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi + if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi + if jq -e '.data.findings[0] | type == "string"' "$TEL" >/dev/null 2>&1; then ok "envelope: findings are flat strings"; else fail "envelope: findings[0] wrong type ($(jq '.data.findings[0]' "$TEL"))"; fi + FREL=$(jq -r '.data.file' "$TEL") + if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi + if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi + if ! printf '%s' "$OUT" | grep -q schema_version 2>/dev/null; then ok "envelope: never leaked into hook's own stdout"; else fail "envelope leaked into stdout"; fi +else + fail "telemetry/stub-sink: no envelope written" +fi +rm -f "$TEL" + +# --- Stub sink + kill switch -> status skipped ------------------------------- +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/tel2.go" +TELS="$(mktemp)" +SINKS="$(make_sink "cat >\"$TELS\"")" +run_hook_env "$REPO/tel2.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=false HOOK_TELEMETRY_SINK="$SINKS" >/dev/null +if [[ -s "$TELS" ]]; then + fail "telemetry/kill-switch: envelope written despite kill switch (should exit before telemetry)" +else + ok "telemetry/kill-switch: no envelope written (hook exits before telemetry setup)" +fi +rm -f "$TELS" + +# --- Missing-tool visibility (dim-9 doctrine) -------------------------------- +# Fake-bin dir of exec wrappers (no goimports): a *.go edit must produce a +# visible once-per-session skip notice on both channels, silent on the +# second run. jq removal then exercises the input-parsing gate. +FAKEBIN="$(mktemp -d -p "$WORK" fakebin.XXXXXX)" +for t in bash jq git dirname basename cat env printf mktemp mkdir find tr awk grep sed uname sleep cygpath realpath readlink rm; do + real_t="$(command -v "$t" 2>/dev/null)" || continue + [[ -n "$real_t" ]] || continue + printf '#!/bin/sh\nexec "%s" "$@"\n' "$real_t" >"$FAKEBIN/$t" + chmod +x "$FAKEBIN/$t" +done +REPO_NG="$WORK/no-goimports" +mkdir -p "$REPO_NG" +git -C "$REPO_NG" init -q +printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO_NG/app.go" +NG_DATA="$(mktemp -d -p "$WORK" plugdata.XXXXXX)" +run_ng() { + ( + cd "$UNRELATED" || return 1 + printf '{"session_id":"test-nogoimports-1","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_NG/app.go" | + env -u CLAUDE_PROJECT_DIR PATH="$FAKEBIN" CLAUDE_PLUGIN_DATA="$NG_DATA" \ + CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true bash "$HOOK" + ) +} +OUT_NG=$(run_ng) +RC_NG=$? +if [[ $RC_NG -eq 0 ]]; then ok "goimports-absent -> exit 0"; else fail "goimports-absent exit $RC_NG"; fi +if jq -e '(.systemMessage | contains("goimports")) and (.hookSpecificOutput.additionalContext | contains("goimports"))' <<<"$OUT_NG" >/dev/null 2>&1; then + ok "goimports-absent -> visible notice on both channels" +else + fail "goimports-absent: notice missing or malformed: $OUT_NG" +fi +OUT_NG2=$(run_ng) +if [[ -z "$OUT_NG2" ]]; then + ok "goimports-absent -> second run same session is silent (once-per-session)" +else + fail "goimports-absent second run not silent: $OUT_NG2" +fi + +# jq-absent -> visible once-per-session notice (input parsing gate). +rm -f "$FAKEBIN/jq" +JQ_DATA="$(mktemp -d -p "$WORK" plugdata.XXXXXX)" +OUT_NOJQ=$( + cd "$UNRELATED" || exit 1 + printf '{"session_id":"test-nojq-1","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_NG/app.go" | + env -u CLAUDE_PROJECT_DIR PATH="$FAKEBIN" CLAUDE_PLUGIN_DATA="$JQ_DATA" \ + CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true bash "$HOOK" +) +RC_NOJQ=$? +if [[ $RC_NOJQ -eq 0 && "$OUT_NOJQ" == *'"systemMessage"'* && "$OUT_NOJQ" == *jq* ]]; then + ok "jq-absent -> exit 0 with visible notice" +else + fail "jq-absent (rc=$RC_NOJQ out=$OUT_NOJQ)" +fi + +echo +echo "PASS=$PASS FAIL=$FAIL" +[[ $FAIL -eq 0 ]] diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh new file mode 100644 index 000000000..a5234be7b --- /dev/null +++ b/plugins/go-format/hooks/hook-utils.sh @@ -0,0 +1,1048 @@ +# shellcheck shell=bash +# Shared hook utility library for this marketplace's hook plugins. Sourced +# (not executed): kill switch, file_path parsing + path normalization, +# repo-root resolution, additionalContext accumulator, telemetry envelope. +# +# SINGLE SOURCE OF TRUTH: lib/hook-utils.sh at the marketplace repo root. The +# copies at plugins/*/hooks/hook-utils.sh exist because installed plugins are +# cache-isolated and must be self-contained — never edit a copy. Edit the +# source and run scripts/sync-hook-utils.sh; CI rejects drifted copies. + +# Guard against double-sourcing. +[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0 +readonly _HOOK_UTILS_LOADED=1 + +# Per-hook kill switch via the plugin's _enabled userConfig boolean, +# read from the hook-process CLAUDE_PLUGIN_OPTION__ENABLED mirror. +# Exits 0 (allow) if disabled. Place after source, before stdin parsing. +# hook::check_enabled "MARKDOWN_FORMAT" # checks CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED +hook::check_enabled() { + local var_name="CLAUDE_PLUGIN_OPTION_${1}_ENABLED" + if [[ "${!var_name:-true}" != "true" ]]; then + exit 0 + fi +} + +# --- Prerequisite visibility -------------------------------------------------- +# Doctrine: a missing runtime prerequisite must surface to BOTH the agent +# (additionalContext) and the user (systemMessage) — a silently skipped feature +# is a defect. Everything in this section is jq-FREE by design: the most common +# missing prerequisite is jq itself. + +# JSON-escape a string for embedding in a hand-built JSON document. Escapes +# backslash, double quote, and the line-structure control bytes by name +# (\n \r \t); the remaining C0 bytes JSON forbids raw are dropped — notice text +# never carries meaningful control bytes beyond line structure. Byte-safe under +# UTF-8: every escaped byte is ASCII, and UTF-8 continuation bytes are >= 0x80. +hook::json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + # tr drops the residual C0 bytes; if tr itself is unavailable, fall back to + # the escaped string as-is — notice text is hook-authored and does not carry + # raw control bytes in practice. + local out + out=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' 2>/dev/null) || out="$s" + printf '%s' "$out" +} + +# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a +# user-channel message (systemMessage) as ONE document — CC parses the hook's +# whole stdout as a single JSON doc, so a run that has both lint findings and a +# pending skip notice must compose them here rather than print twice. Either +# channel may be empty; emits nothing when both are. +# hook::emit_channels PostToolUse "$ctx" "$sysmsg" +hook::emit_channels() { + local event="$1" ctx="$2" sysmsg="$3" + [[ -n "$ctx" || -n "$sysmsg" ]] || return 0 + local out="{" + if [[ -n "$ctx" ]]; then + out+='"hookSpecificOutput":{"hookEventName":"'"$(hook::json_escape "$event")"'","additionalContext":"'"$(hook::json_escape "$ctx")"'"}' + [[ -n "$sysmsg" ]] && out+="," + fi + [[ -n "$sysmsg" ]] && out+='"systemMessage":"'"$(hook::json_escape "$sysmsg")"'"' + out+="}" + printf '%s\n' "$out" +} + +# Visible skip notice: the same message on both channels. The caller must exit 0 +# right after unless it composes via hook::emit_channels itself. +# hook::emit_skip_notice PostToolUse "my-plugin: tool X not found — ..." +hook::emit_skip_notice() { + hook::emit_channels "$1" "$2" "$2" +} + +# systemMessage-only variant for hook events with no additionalContext channel +# (e.g. Notification). +hook::emit_system_message() { + hook::emit_channels "" "" "$1" +} + +# Once-per-session gate for skip notices. Returns 0 (emit now) the first time a +# given fires in the current session, 1 afterwards — a missing-tool notice +# behind a broad matcher (every Write|Edit) must not repeat on every edit. The +# session id is regex-extracted from the raw hook input JSON (jq-free, see +# section header); marker files live under ${CLAUDE_PLUGIN_DATA} (survives +# plugin updates; mkdir -p defensively since creation is documented only on +# first *reference*) and markers older than 7 days are pruned so per-session +# files cannot accumulate unboundedly. Fails open toward visibility: when no +# marker can be tracked, emit every time. +# hook::notice_once "my-plugin-jq" "$INPUT" && hook::emit_skip_notice ... +hook::notice_once() { + local key="$1" input="${2:-}" session="no-session" + if [[ "$input" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then + session="${BASH_REMATCH[1]}" + session="${session//[^A-Za-z0-9_-]/-}" + fi + local dir="${CLAUDE_PLUGIN_DATA:-}" + [[ -n "$dir" ]] || return 0 + dir="$dir/skip-notices" + mkdir -p "$dir" 2>/dev/null || return 0 + find "$dir" -type f -mtime +7 -delete 2>/dev/null + local marker="$dir/${key}.${session}" + [[ -f "$marker" ]] && return 1 + : >"$marker" 2>/dev/null + return 0 +} + +# Best-effort jq-free extraction of tool_input.file_path from the raw hook +# input, for the applicability pre-filter an extension-scoped hook runs BEFORE +# its jq gate — a missing-jq notice must never fire for an edit the hook would +# not process anyway (e.g. a README edit reaching a workflow-lint hook whose +# Write|Edit matcher is broader than its file filter). The value is returned +# JSON-escaped (backslashes doubled); that is fine for extension/segment +# matching, which is all the pre-filter does. Returns 1 when no file_path is +# present. +# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0 +hook::raw_file_path() { + [[ "$1" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1 + [[ -n "${BASH_REMATCH[1]}" ]] || return 1 + printf '%s' "${BASH_REMATCH[1]}" +} + +# jq gate for hooks whose input parsing cannot proceed without it. When jq is +# absent: one visible skip notice per session, then exit 0 — an advisory hook +# never blocks the tool over a missing prerequisite. Place after +# hook::check_enabled (and after any jq-free applicability pre-filter), passing +# the buffered stdin for session scoping. +# hook::require_jq PostToolUse my-plugin "$INPUT" +hook::require_jq() { + command -v jq >/dev/null 2>&1 && return 0 + local event="$1" plugin="$2" input="${3:-}" + if hook::notice_once "${plugin}-jq" "$input"; then + hook::emit_skip_notice "$event" \ + "$plugin: jq not found on PATH — hook skipped for this session. Install jq (https://jqlang.org/download/) to enable it." + fi + exit 0 +} + +# Normalize a path for the membership comparison below: backslashes → forward +# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive — +# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive +# letter + lower-cased remainder so the byte-exact comparison is effectively +# case-insensitive. The fold is gated on the host (OSTYPE), NOT on the path +# shape: on a case-sensitive POSIX filesystem a real single-letter top-level +# directory such as `/c/Repo` must pass through unchanged, otherwise it would +# collapse with `/c/repo` and the membership guard would admit a sibling +# outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the +# emitted path is always the caller's original. +hook::normalize_path() { + local p="${1//\\//}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then + local rest="${p:2}" + printf '%s' "${BASH_REMATCH[1]^}:${rest,,}" + return + fi + ;; + *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below + esac + printf '%s' "$p" +} + +# Canonicalize to a physical path — symlinks resolved — for the membership +# comparison below, so an in-project symlink pointing outside the project root +# cannot defeat the guard (the lexical path would pass the prefix check while +# the write lands elsewhere). GNU realpath ships with Git Bash and Linux +# coreutils; readlink -f covers the BSD/macOS hosts that have no realpath. +# When neither resolver exists the caller falls back to comparing the lexical +# path as before — the guard is defense-in-depth scoping for a file the agent +# already wrote via its own tools, so degrading to the historical comparison +# beats silently disabling the hook on those hosts. +hook::physical_path() { + local resolved + if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then + if [[ -n "$resolved" ]]; then + printf '%s' "$resolved" + return + fi + fi + printf '%s' "$1" +} + +# Parse file_path from PostToolUse JSON on stdin; validate existence and (when +# CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership +# comparison are canonicalized (symlinks resolved) first, so neither an +# escaping symlink nor a project root reached via a symlinked path (e.g. +# macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip. +# FILE=$(hook::read_file_path) || exit 0 +hook::read_file_path() { + local file + file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null) + [[ -n "$file" ]] || return 1 + [[ -f "$file" ]] || return 1 + if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then + local norm_file norm_project + norm_file=$(hook::normalize_path "$(hook::physical_path "$file")") + norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")") + norm_project="${norm_project%/}" + # Anchor on a path-segment boundary: accept the project root itself or a + # child under it, but not a sibling whose name merely shares the prefix + # (e.g. /c/repo must not admit /c/repo-backup/...). + if [[ "$norm_file" != "$norm_project" && "$norm_file" != "$norm_project"/* ]]; then + return 1 + fi + fi + printf '%s' "$file" +} + +# Resolve the repository root (working-tree top) for a path inside the tree. +# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here +# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`) +# so it is correct for clones, linked worktrees, and bare-hub clones; falls +# back to the hint (with a trailing /.claude stripped) when git cannot resolve. +# ROOT=$(hook::repo_root "$some_path") +hook::repo_root() { + local hint="${1:-.}" + local root + root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r') + if [[ -z "$root" ]]; then + root="$hint" + root="${root%/.claude}" + root="${root%\\.claude}" + fi + printf '%s' "$root" +} + +# Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe +# late-EOF stalls via a bounded read on the inherited fd0. Returns the payload +# on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the +# read timed out before a complete JSON payload arrived (caller may block). +# Bound is the stdin_read_timeout userConfig option in seconds (read via +# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present) +# distinguishes a truncated read from a genuinely small-but-complete payload; a +# missing/broken jq (exit 127) fails open like absent jq. +# INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin() { + local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms + start_epoch=${EPOCHREALTIME:-} + IFS= read -r -d '' -t "$read_timeout" input || read_status=$? + input=$(printf '%s' "$input" | tr -d '\r') + [[ -n "$input" ]] || return 1 + local jq_rc=0 + if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then + jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$? + fi + if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then + elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }') + timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }') + if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] && + ((elapsed_ms + 100 >= timeout_ms)); then + echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 + return 2 + fi + return 1 + fi + printf '%s' "$input" +} + +# Extract a single jq field from a buffered input string. CR-stripped. Returns 1 +# when the field is empty or jq fails, so the caller can skip. +# FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0 +hook::jq_field() { + local field + field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null) + [[ -n "$field" ]] || return 1 + printf '%s' "$field" +} + +# Reduce a tool + optional Bash command to a privacy-safe subject label. For +# Bash, returns "Bash:" (leading sudo / VAR=val prefixes stripped, +# basename applied) — never the full command. For any other tool, returns the +# tool name unchanged. Carries no argument body, path, or command tail. +# +# Whitespace-splitting is only safe when no quoted value spans the whitespace. +# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a +# fragment of the value into the token, so any token carrying a quote aborts to a +# bare "Bash" subject rather than risk exposing part of the value. +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +hook::extract_bash_subject() { + local tool="$1" cmd="${2:-}" + if [[ "$tool" != "Bash" ]]; then + printf '%s' "$tool" + return 0 + fi + # Trim leading whitespace so the first token is real. + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + local first_token="${cmd%%[[:space:]]*}" + while [[ "$first_token" == "sudo" || "$first_token" == *=* ]] && + [[ -n "$cmd" && "$cmd" == *[[:space:]]* ]]; do + # A quote in the prefix token means a quoted value spans the next whitespace; + # we cannot tokenize it safely — bail rather than leak a value fragment. + if [[ "$first_token" == *[\"\']* ]]; then + printf '%s' "$tool" + return 0 + fi + cmd="${cmd#*[[:space:]]}" + cmd="${cmd#"${cmd%%[![:space:]]*}"}" + first_token="${cmd%%[[:space:]]*}" + done + # The resolved command token itself must not carry a quote (e.g. a value that + # ended here), which would likewise be a value fragment. + if [[ "$first_token" == *[\"\']* ]]; then + printf '%s' "$tool" + return 0 + fi + first_token="${first_token##*/}" + if [[ -n "$first_token" ]]; then + printf 'Bash:%s' "$first_token" + else + printf '%s' "$tool" + fi +} + +# Append one line to a JSONL file, serialized under an flock advisory lock when +# flock is present (bounded 2s wait; a lost race drops the line rather than +# blocking) and a best-effort bare append otherwise. Fire-and-forget: never +# fails the caller. Used by audit hooks that maintain a bespoke second store. +# hook::append_jsonl +hook::append_jsonl() { + local file="$1" line="$2" + if command -v flock >/dev/null 2>&1; then + ( + flock -w 2 9 || exit 0 + printf '%s\n' "$line" >>"$file" + ) 9>"${file}.lock" 2>/dev/null + else + printf '%s\n' "$line" >>"$file" 2>/dev/null + fi +} + +# Per-hook stdout context accumulator. ctx_reset at entry, ctx_append per line, +# ctx_flush once at exit with the hook event name. +_HOOK_CTX_BUFFER="" + +hook::ctx_reset() { + _HOOK_CTX_BUFFER="" +} + +hook::ctx_append() { + _HOOK_CTX_BUFFER+="$1"$'\n' +} + +# Emit the accumulated context as hookSpecificOutput JSON, then clear the buffer. +hook::ctx_flush() { + local event_name="$1" + local trimmed="${_HOOK_CTX_BUFFER%"${_HOOK_CTX_BUFFER##*[![:space:]]}"}" + trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" + hook::emit_additional_context "$event_name" "$trimmed" + hook::ctx_reset +} + +# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers +# gate telemetry-payload construction on this (repo-relative path +# normalization, data JSON) so the unwired default path spawns zero +# telemetry-only subprocesses. Pure shell test, no subprocess. +# hook::emit_telemetry re-checks the sink itself, so skipping this probe +# costs only wasted payload work, never correctness. +hook::telemetry_enabled() { + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] +} + +# Emit one telemetry envelope per hook run to the consumer-set sink. +# Fire-and-forget: sink is dispatched in the background; the hook never waits +# on it and its failure never affects the hook's own exit code or stdout. +# Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately. +# Fail-open: jq absent → return 0 immediately. +# +# Usage: +# hook::emit_telemetry [repo_root] +# +# Value of $EPOCHREALTIME captured by the caller before work began. +# Handles both '.' and ',' as the decimal separator (LC_NUMERIC). +# Pre-built JSON object for the `data` field. +# Optional consuming-repo root, used to resolve a RELATIVE +# HOOK_TELEMETRY_SINK. The caller passes the root it already +# resolved for data.file; ignored when the sink is absolute. +# +# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the +# consuming repo root. Absolute (POSIX /… or Windows X:\ / X:/) is used as-is; a +# relative value is joined onto (or $CLAUDE_PROJECT_DIR when no root +# is passed), and skipped fail-open if neither is available. Relative is the +# portable, team-shared wiring form: CC injects settings.json env values +# literally (no ${VAR} expansion), so a relative path tracked in settings.json is +# the only clone-portable, worktree-safe option. +# +# NEVER writes to fd1 (the hook's stdout / additionalContext channel). +hook::emit_telemetry() { + # Opt-in guard. + [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0 + # Fail-open when jq is absent. + command -v jq >/dev/null 2>&1 || return 0 + + local hook_id="$1" + local hook_event="$2" + local status="$3" + local start_epoch="$4" + local data_json="$5" + local repo_root="${6:-}" + + # Compute duration_ms from caller's $EPOCHREALTIME snapshot to now. + # Both '.' and ',' separators handled; 10# prefix prevents octal misreading + # of fractional parts with leading zeros (e.g. .045123 → 10#045123 = 45123). + # EPOCHREALTIME is Bash 5.0+; on an older host it (and the caller's start + # snapshot) is empty. Skip telemetry fail-open rather than abort under set -u — + # the same silent-skip the caller's `START=${EPOCHREALTIME:-}` guard intends. + local now=${EPOCHREALTIME:-} + [[ -n "$start_epoch" && -n "$now" ]] || return 0 + local s_s="${start_epoch%[.,]*}" s_f="${start_epoch#*[.,]}" + local e_s="${now%[.,]*}" e_f="${now#*[.,]}" + local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000)) + + # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie). + local timestamp + timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1) + + # Build the envelope. Redirect jq stderr to /dev/null; output goes to a local + # variable — never to fd1. + local envelope + envelope=$(jq -n \ + --arg schema_version "1.0" \ + --arg timestamp "$timestamp" \ + --arg hook "$hook_id" \ + --arg hook_event "$hook_event" \ + --arg status "$status" \ + --argjson duration_ms "$duration_ms" \ + --argjson data "$data_json" \ + '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:$data}' \ + 2>/dev/null) || return 0 + + # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the + # consuming repo root (portable, tracked wiring); absolute is used as-is. A + # relative value with no anchor is skipped fail-open — never exec a path the + # drifted hook CWD would resolve incorrectly. + local sink="$HOOK_TELEMETRY_SINK" + case "$sink" in + /* | [A-Za-z]:[/\\]*) ;; + *) + local root="${repo_root:-${CLAUDE_PROJECT_DIR:-}}" + [[ -n "$root" ]] || return 0 + sink="${root%/}/$sink" + ;; + esac + + # Fire-and-forget: pipe the envelope to the sink in a background subshell. + # The subshell's stdout AND stderr are redirected to /dev/null so the sink + # cannot write to the hook's fd1 (the additionalContext channel) and the + # backgrounded subshell does not hold a copy of the hook's fd1 open — which + # would block any command substitution wrapping the hook until the sink exits + # (the "C1 fd1-inheritance blocker"). The sink is quoted — it is a single + # executable path (wrap in a script to pass arguments). + printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) & +} + +# Print cross-host hook JSON to stdout (exit 0). No-op when context is empty. +# Shape: { hookSpecificOutput: { hookEventName[, additionalContext] } }. +hook::emit_additional_context() { + local event_name="$1" + local context="$2" + [[ -n "$context" ]] || return 0 + command -v jq >/dev/null 2>&1 || return 0 + jq -n \ + --arg event "$event_name" \ + --arg ctx "$context" \ + '{hookSpecificOutput: ( + {hookEventName: $event} + + (if $ctx != "" then {additionalContext: $ctx} else {} end) + )}' +} + +# --------------------------------------------------------------------------- +# Argv-grammar-faithful Bash command parsing for git guards. The command is +# parsed the way the shell builds argv — top-level segments split on unquoted +# control operators, each tokenized into argv words honoring '…', "…", $'…' +# (ANSI-C), and backslash escapes — then a real git executable is resolved at +# the segment's command position past env-var assignments and known wrappers, +# and its subcommand resolved past git global options. +# +# Static matching over the literal command string only: shell variable and +# command substitution ($VAR, $(…)) are NOT evaluated. Guards built on this +# are friction against accidental/casual bypass, not a sandbox. + +# Decode an ANSI-C `$'…'` body to its literal bytes (\xHH, \NNN octal, \uHHHH, +# \n, \\, …). %-escaped so the body can never act as a printf format specifier; +# `--` guards a body that begins with `-`. Errors are swallowed (fail-open on a +# malformed body — the raw text still flows through the caller unchanged). +hook::ansi_c_decode() { + local b="${1//%/%%}" + # shellcheck disable=SC2059 # the body IS the format — that is how ANSI-C escapes decode; %-escaped above so it cannot inject a specifier + printf -- "$b" 2>/dev/null +} + +# Split a GNU `env -S` operand the way env does: whitespace-separated words +# honoring "…" and '…' quotes and backslash escapes — so a flag quoted inside +# the operand (`env -S 'git push "--force"'`) still surfaces as its unquoted +# argv word. env's $VAR expansion inside the operand is NOT evaluated (static +# analysis over the literal string — same residual as the segment tokenizer). +# Result in the global HOOK_ENV_S_WORDS array. +# shellcheck disable=SC2034 # result global is consumed by hook::git_resolve_index +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +hook::env_s_split() { + local s="$1" i c n=${#1} word="" have=0 + HOOK_ENV_S_WORDS=() + for ((i = 0; i < n; i++)); do + c="${s:i:1}" + case "$c" in + "'") + ((i++)) + while ((i < n)) && [[ "${s:i:1}" != "'" ]]; do + word+="${s:i:1}" + ((i++)) + done + have=1 + ;; + '"') + ((i++)) + while ((i < n)) && [[ "${s:i:1}" != '"' ]]; do + if [[ "${s:i:1}" == '\' ]] && ((i + 1 < n)); then + word+="${s:i+1:1}" + ((i += 2)) + continue + fi + word+="${s:i:1}" + ((i++)) + done + have=1 + ;; + '\') + if ((i + 1 < n)); then + word+="${s:i+1:1}" + ((i++)) + fi + have=1 + ;; + ' ' | $'\t') + if ((have)); then + HOOK_ENV_S_WORDS+=("$word") + word="" + have=0 + fi + ;; + *) + word+="$c" + have=1 + ;; + esac + done + ((have)) && HOOK_ENV_S_WORDS+=("$word") +} + +# Detect a `sh -c` style shell wrapper in a segment's argv: a shell at the +# command position (after leading env-var assignments) carrying a `-c` flag. +# On match, the command-string operand lands in HOOK_SHELL_C_OPERAND for the +# caller to re-parse with hook::bash_parse_segments — the operand is a full +# shell command (operators, quoting, everything), so re-parsing with the same +# tokenizer is the faithful treatment. Wrappers stacked in front of the shell +# (`sudo bash -c …`) are NOT resolved here — a documented residual of the +# static-matcher posture. A shell invoked on a script file (no -c) never +# matches: file contents cannot be inspected statically. +# shellcheck disable=SC2034 # result global is consumed by the sourcing guard +hook::shell_c_operand() { + local -a w=("$@") + local n=${#w[@]} i=0 b t has_c=0 + # Skip leading VAR=val assignments, mirroring the git resolver. + while ((i < n)) && [[ "${w[i]}" == *=* && "${w[i]}" != -* ]]; do ((i++)); done + ((i < n)) || return 1 + b="${w[i]##*/}" + b="${b##*\\}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + b="${b,,}" + b="${b%.exe}" + ;; + *) ;; + esac + case "$b" in + bash | sh | zsh | dash | ksh | mksh) ;; + *) return 1 ;; + esac + ((i++)) + while ((i < n)); do + t="${w[i]}" + case "$t" in + --) + ((i++)) + break + ;; + # -o/-O (and +o/+O) consume a set/shopt operand; --rcfile/--init-file + # consume a startup-file operand (bash) — none of these ends the option + # scan, so `bash --rcfile /dev/null -c '…'` still reaches its -c. + -o | +o | -O | +O | --rcfile | --init-file) ((i += 2)) ;; + -*) + [[ "$t" =~ ^-[A-Za-z]+$ && "$t" == *c* ]] && has_c=1 + ((i++)) + ;; + *) break ;; + esac + done + ((has_c)) || return 1 + ((i < n)) || return 1 + HOOK_SHELL_C_OPERAND="${w[i]}" + return 0 +} + +# Does an argv word name the git executable? Basename compared exactly on +# POSIX; on Windows/MSYS also case-folded and `.exe`-stripped (mirrors the +# OS-gate in hook::normalize_path) so `GIT` / `git.exe` are caught there but a +# case-variant stays distinct on a case-sensitive POSIX filesystem. +hook::git_is_bin() { + local b="${1##*/}" + b="${b##*\\}" + case "${OSTYPE:-}" in + msys* | cygwin* | win32) + local lc="${b,,}" + lc="${lc%.exe}" + [[ "$lc" == "git" ]] + ;; + *) [[ "$b" == "git" ]] ;; + esac +} + +# Locate a real `git` executable at the segment's command position (after +# env-var prefixes and known wrappers), or return 1 when absent. Results go in +# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller +# must match on the rewritten words, so the index alone is not enough. +# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS +# HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +# shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file +hook::git_resolve_index() { + HOOK_GIT_RESOLVED_WORDS=("$@") + HOOK_GIT_RESOLVED_GI=-1 + # shellcheck disable=SC2178 # nameref to the array result global, not a string assignment + local -n w=HOOK_GIT_RESOLVED_WORDS + local n=${#w[@]} i=0 tok + + while ((i < n)); do + tok="${w[i]}" + if [[ "$tok" == *=* ]]; then + ((i++)) + continue + fi + + case "${tok##*/}" in + env) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + # -S/--split-string re-splits its operand into argv (GNU env), so a + # quoted 'git commit --no-verify' would otherwise hide from the + # resolver as one non-git word. Splice the split words back into the + # scan and restart at the command position. + -S | --split-string) + local sval="" + ((i + 1 < n)) && sval="${w[i + 1]}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}") + n=${#w[@]} + i=0 + continue 2 + ;; + -S* | --split-string=*) + local sval="${w[i]#-S}" + sval="${sval#--split-string=}" + hook::env_s_split "$sval" + w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}") + n=${#w[@]} + i=0 + continue 2 + ;; + -u | --unset | -C | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + done + continue + ;; + nice | nohup) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -n | --adjustment) ((i += 2)) ;; + --adjustment=*) ((i++)) ;; + -*) ((i++)) ;; + *) break ;; + esac + done + if ((i < n)) && [[ "${w[i]}" =~ ^-?[0-9]+$ ]]; then + ((i++)) + fi + continue + ;; + sudo) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -u | -g | -h | -p | -C | -D | -R | -T | --user | --group | --chdir) ((i += 2)) ;; + -*) ((i++)) ;; + *) ((i++)) ;; + esac + done + continue + ;; + timeout) + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* ]]; do + case "${w[i]}" in + -s | --signal | -k | --kill-after) ((i += 2)) ;; + --preserve-status | --foreground | --verbose) ((i++)) ;; + -*) ((i++)) ;; + *) break ;; + esac + done + if ((i < n)) && [[ "${w[i]}" =~ ^[0-9]+([.][0-9]+)?(s|m|h|d)?$ ]]; then + ((i++)) + fi + continue + ;; + # eval concatenates and re-executes its arguments, so for the unquoted + # form (`eval git commit ...`) scanning the following words is exact. + # command/exec carry their own options before the real command + # (`command [-pVv]`, `exec [-cl] [-a name]`) and an optional `--` + # end-of-options marker (`command -- git …`) — skip them so the git + # command behind the wrapper is still resolved. But `command -v`/`-V` + # only PRINT the command's path/description — nothing runs — so a git + # word behind them is a probe, not an invocation: bail out. + command | exec | builtin | eval | !) + local is_command=0 + [[ "${tok##*/}" == "command" ]] && is_command=1 + ((i++)) + while ((i < n)) && [[ "${w[i]}" == -* && "${w[i]}" != "--" ]]; do + if ((is_command)) && [[ "${w[i]}" == -*[vV]* ]]; then + return 1 + fi + [[ "${w[i]}" == "-a" ]] && ((i++)) + ((i++)) + done + ((i < n)) && [[ "${w[i]}" == "--" ]] && ((i++)) + continue + ;; + time) + ((i++)) + if ((i < n)) && [[ "${w[i]}" == "-p" ]]; then + ((i++)) + fi + continue + ;; + # Compound-command reserved words at the execution position: a command + # can directly follow any of these within one segment (`if git …`, + # `then git …`, `do git …`), so skip them and keep resolving. + if | then | elif | else | while | until | for | do | case | select | coproc | '{' | '}') + ((i++)) + continue + ;; + *) + if hook::git_is_bin "$tok"; then + HOOK_GIT_RESOLVED_GI=$i + return 0 + fi + return 1 + ;; + esac + done + return 1 +} + +# Resolve the git subcommand in an already-resolved segment: walk words after +# the git executable, skipping git global options. The listed options consume +# the FOLLOWING word as their value (two-word form); their =-forms and every +# other option are single words handled by the generic `-*` skip. Results in +# globals: +# HOOK_GIT_SUB — the subcommand word ("" when none found) +# HOOK_GIT_SUB_IDX — its index in the argv (-1 when none) +# HOOK_GIT_CONFIG_VALUES — values of -c/--config/--config-env options, in +# order, so a guard can inspect config assignments +# without re-walking (commit messages and pathspecs +# are never collected here) +# Call as: hook::git_resolve_subcommand +# shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file +hook::git_resolve_subcommand() { + local gi="$1" + shift + local -a w=("$@") + local nseg=${#w[@]} j gw + HOOK_GIT_SUB="" + HOOK_GIT_SUB_IDX=-1 + HOOK_GIT_CONFIG_VALUES=() + + j=$((gi + 1)) + while ((j < nseg)); do + gw="${w[j]}" + case "$gw" in + -c | --config | --config-env) + ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}") + ((j += 2)) + ;; + --config=* | --config-env=*) + HOOK_GIT_CONFIG_VALUES+=("${gw#*=}") + ((j++)) + ;; + -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path) + ((j += 2)) + ;; + -*) + ((j++)) + ;; + *) + HOOK_GIT_SUB="$gw" + HOOK_GIT_SUB_IDX=$j + return 0 + ;; + esac + done + return 1 +} + +# Single linear pass: read the command into a char array once (O(n)), then walk +# it splitting top-level segments on UNQUOTED control operators and tokenizing +# each segment into argv words honoring '…', "…", $'…', and backslash escapes +# (including backslash-newline continuation). Each completed segment is passed +# to the callback as it closes, so no full segment list is retained. +# Call as: hook::bash_parse_segments ; the callback +# receives one segment's argv words as "$@". +# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape +hook::bash_parse_segments() { + local cmd="$1" cb="$2" + local -a chars=() + local c nx + while IFS= read -rN1 c; do chars+=("$c"); done < <(printf '%s' "$cmd") + local n=${#chars[@]} i + local word="" have=0 skipnext=0 + local -a seg=() + # Pending heredoc delimiters (FIFO) and their `<<-` tab-strip flags. A + # heredoc body is the command's stdin, not commands — recorded when `<<` + # is seen and skipped wholesale at the command-line newline. + local -a hd_delims=() hd_strip=() + + for ((i = 0; i < n; i++)); do + c="${chars[i]}" + case "$c" in + "'") + ((i++)) + while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do + word+="${chars[i]}" + ((i++)) + done + have=1 + ;; + '"') + ((i++)) + while ((i < n)) && [[ "${chars[i]}" != '"' ]]; do + if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then + nx="${chars[i + 1]}" + case "$nx" in + '"' | '\' | '$' | '`') + word+="$nx" + ((i += 2)) + continue + ;; + $'\n') + ((i += 2)) + continue + ;; + *) ;; + esac + fi + word+="${chars[i]}" + ((i++)) + done + have=1 + ;; + '$') + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == "'" ]]; then + i=$((i + 2)) + local body="" + while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do + if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then + body+="${chars[i]}${chars[i + 1]}" + ((i += 2)) + continue + fi + body+="${chars[i]}" + ((i++)) + done + word+="$(hook::ansi_c_decode "$body")" + have=1 + else + word+="$c" + have=1 + fi + ;; + '\') + if ((i + 1 < n)); then + nx="${chars[i + 1]}" + if [[ "$nx" == $'\n' ]]; then + ((i++)) + else + word+="$nx" + ((i++)) + have=1 + fi + else + have=1 + fi + ;; + ' ' | $'\t') + if ((have)); then + if ((skipnext)); then skipnext=0; else seg+=("$word"); fi + word="" + have=0 + fi + ;; + '>' | '<') + # Redirection: bash removes the operator and its target word from + # argv (redirections may appear anywhere in a simple command), so + # `git reset --hard>/tmp/out` still runs reset --hard. A pure-digit + # word immediately before the operator is its fd prefix, not argv; + # an fd-dup/close form (`2>&1`, `>&-`) has no target word to skip. + if ((have)); then + if [[ "$word" =~ ^[0-9]+$ ]]; then + : + elif ((skipnext)); then + skipnext=0 + else + seg+=("$word") + fi + word="" + have=0 + fi + # Heredoc `<<` / `<<-` (but NOT here-string `<<<`): the body on the + # following lines is the command's stdin, so record the delimiter and + # let the newline handler skip the body. A quoted/backslashed delimiter + # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`. + if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \ + && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then + ((i++)) + local hstrip=0 + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then + hstrip=1 + ((i++)) + fi + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == ' ' || "${chars[i + 1]}" == $'\t' ]]; do ((i++)); done + local delim="" + while ((i + 1 < n)); do + nx="${chars[i + 1]}" + case "$nx" in + ' ' | $'\t' | $'\n' | ';' | '&' | '|' | '<' | '>') break ;; + "'") + ((i++)) + while ((i + 1 < n)) && [[ "${chars[i + 1]}" != "'" ]]; do + delim+="${chars[i + 1]}" + ((i++)) + done + ((i + 1 < n)) && ((i++)) + ;; + '"') + ((i++)) + while ((i + 1 < n)) && [[ "${chars[i + 1]}" != '"' ]]; do + delim+="${chars[i + 1]}" + ((i++)) + done + ((i + 1 < n)) && ((i++)) + ;; + '\') + ((i++)) + ((i + 1 < n)) && { + delim+="${chars[i + 1]}" + ((i++)) + } + ;; + *) + delim+="$nx" + ((i++)) + ;; + esac + done + hd_delims+=("$delim") + hd_strip+=("$hstrip") + continue + fi + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '(' ]]; then + # Process substitution <(list)/>(list): the list is a real command + # substituted as a filename — it satisfies any pending target and + # the '(' separator splits it into a segment that gets scanned. + skipnext=0 + else + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then + ((i++)) + if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then + while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; do ((i++)); done + else + skipnext=1 + fi + else + skipnext=1 + fi + fi + ;; + ';' | '&' | '|' | '(' | ')' | '`' | $'\n') + if ((have)); then + if ((skipnext)); then skipnext=0; else seg+=("$word"); fi + word="" + have=0 + fi + if ((${#seg[@]})); then + "$cb" "${seg[@]}" + seg=() + fi + # A command-line newline ends the line that introduced any pending + # heredocs; their bodies (up to and including each delimiter line) are + # stdin, so consume them without tokenizing. Delimiters match in FIFO + # order; `<<-` strips leading tabs from body lines before comparing. + if [[ "$c" == $'\n' ]] && ((${#hd_delims[@]})); then + local hidx line lc d strip + for ((hidx = 0; hidx < ${#hd_delims[@]}; hidx++)); do + d="${hd_delims[hidx]}" + strip="${hd_strip[hidx]}" + while ((i + 1 < n)); do + line="" + while ((i + 1 < n)) && [[ "${chars[i + 1]}" != $'\n' ]]; do + line+="${chars[i + 1]}" + ((i++)) + done + ((i + 1 < n)) && ((i++)) + lc="$line" + if ((strip)); then + while [[ "$lc" == $'\t'* ]]; do lc="${lc#?}"; done + fi + [[ "$lc" == "$d" ]] && break + done + done + hd_delims=() + hd_strip=() + fi + ;; + *) + word+="$c" + have=1 + ;; + esac + done + if ((have)) && ((!skipnext)); then seg+=("$word"); fi + if ((${#seg[@]})); then "$cb" "${seg[@]}"; fi +} diff --git a/plugins/go-format/hooks/hooks.json b/plugins/go-format/hooks/hooks.json new file mode 100644 index 000000000..964d92025 --- /dev/null +++ b/plugins/go-format/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/go-format.sh", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/plugins/go-format/skills/setup/SKILL.md b/plugins/go-format/skills/setup/SKILL.md new file mode 100644 index 000000000..511e98511 --- /dev/null +++ b/plugins/go-format/skills/setup/SKILL.md @@ -0,0 +1,81 @@ +--- +name: setup +description: "Verify the go-format hook's runtime prerequisites and configuration for this repository. Use when: 'set up go-format', 'configure go-format', 'is go-format working', Go import/formatting fixes silently aren't happening, or the hook reported a missing prerequisite. Actions: check (read-only verification, default) | apply (resolve what check found). Re-runnable and safe." +argument-hint: "check | apply" +user-invocable: true +disable-model-invocation: true +--- + +## Purpose + +Thin check-centric setup per the uniform contract: `check` inspects and reports, `apply` +resolves. This plugin owns no consumer-project configuration — it runs unconditionally (no +consumer-config opt-in gate, unlike sibling formatter plugins Ruff/typos), so the only tunable +is the native `userConfig` toggle. Like `typos-format`, `goimports` has no per-repo +dependency-manager install path in the way Ruff's `.venv` does — it is conventionally +`go install`ed to the machine-global `$GOPATH/bin`, never as a project dependency. `apply` is +therefore guidance-only: it never installs anything, matching the hook's own PATH-only +resolution and the plugin philosophy's never-download-silently rule. + +Action routing: no argument or `check` runs the check; `apply` runs the check first, then +prints remediation guidance for each FAIL. Both are non-interactive — never prompt when the +action is given. + +## `check` (read-only) + +The hook script (`${CLAUDE_PLUGIN_ROOT}/hooks/go-format.sh`) is the single source of truth for +what it requires and how it resolves things. **Read it first** — probe what it actually does, +don't recite this file. Then run each probe via Bash and report a PASS/FAIL/INFO table with one +remediation line per FAIL. Do not modify anything. + +When the plugin's toggle is disabled, every prerequisite absence downgrades from FAIL to +INFO — the hook exits through its enabled-gate before probing anything, so a deliberately +disabled plugin is not broken. Report the probes informationally and note that re-enabling +restores the FAIL semantics. + +1. **Bash version** — check against the hook's documented floor (README Requirements), + noting any features the hook degrades without (telemetry's `EPOCHREALTIME`, Bash 5.0+). +2. **`jq`** — `command -v jq`. FAIL if absent: the hook then skips with a visible + once-per-session notice instead of formatting. +3. **`goimports` binary** — `command -v goimports` (the hook resolves PATH only — no + `.venv`-style per-repo convention). Report the resolved path and `goimports -h`'s first line + when found (goimports has no `--version` flag; the help header is the closest signal). FAIL + when absent — the hook then emits a visible once-per-session skip notice instead of running. +4. **Hook toggle** — report the effective `go_format_enabled` value: + `${user_config.go_format_enabled}` (unexpanded or empty means default `true`). +5. **Hook registration** — INFO: confirm the plugin is enabled for this project + (`/plugin` → Installed) rather than parsing settings files. + +There is no consumer-config probe (unlike `typos-format`'s config-walk check) — this hook runs +unconditionally by design; report that plainly as INFO, not as a gap. + +## `apply` (idempotent) + +Run `check`, then for each FAIL print remediation guidance — never install anything. There is +no `apply install-goimports`-style write path: `go install golang.org/x/tools/cmd/goimports@latest` +writes to the machine-global `$GOPATH/bin` (not project-scoped) and `@latest` is not +idempotent-pinned, so the only responsible action is pointing at the command and letting the +consumer run it themselves. + +After the consumer installs `goimports` themselves, re-run `check` and report its actual +result — never claim resolved without re-verifying. For everything else `apply` only points: + +- missing `goimports`: `go install golang.org/x/tools/cmd/goimports@latest` (requires a Go + toolchain: https://go.dev/dl/). +- missing `jq` / Bash: platform install instructions from the README Requirements section; + this skill never installs system packages. +- toggle off: direct to `/plugin configure go-format` (interactive, any + time). Headless: `--config` only applies on a fresh install (ignored once installed), so + reconfigure via `claude plugin uninstall go-format` then + `claude plugin install go-format@ --config go_format_enabled=true`; + this skill never writes user settings or `pluginConfigs`. + +Re-running `apply` after everything passes changes nothing and reports "already configured". + +## What this skill does NOT do + +- Run the formatter — editing any `.go` file exercises the hook end-to-end. +- Write the plugin cache, Claude Code user settings, or `pluginConfigs`. +- Install `goimports` — installation is always the consumer's own choice and command, at the + machine level, never a project dependency this skill records. +- Download or execute tools during `check` or `apply`. diff --git a/plugins/toolchain/.claude-plugin/plugin.json b/plugins/toolchain/.claude-plugin/plugin.json index 9313543ec..95ba05c3f 100644 --- a/plugins/toolchain/.claude-plugin/plugin.json +++ b/plugins/toolchain/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "toolchain", - "version": "0.7.0", - "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).", + "version": "0.8.0", + "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, Go, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/toolchain/CHANGELOG.md b/plugins/toolchain/CHANGELOG.md index 7a9373314..2e371480c 100644 --- a/plugins/toolchain/CHANGELOG.md +++ b/plugins/toolchain/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to the `toolchain` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.0] + +### Added + +- **`go` ecosystem batch default** (`build-cmd: go build ./...`, `test-cmd: go test ./...`, + `check-cmd`/`fix-cmd: golangci-lint run [--fix] ./...`, `project-discovery: ["go.mod"]` for + nested-module coverage, a `go-mod-tidy-drift` gate via `go mod tidy -diff`) added to + `reference/ecosystems/go.yaml` — closes the Go toolchain CI/local-parity gap. Gated behind an + `opt-in` key (`.golangci.yml`/`.golangci.yaml`/`.golangci.toml`/`.golangci.json` presence) — + empirically verified golangci-lint v2 with no config file still applies its own fixed "standard" + linter preset unconditionally, the same imposed-unconfigured-opinion risk the 0.6.0 dotnet gate + addressed. +- `context/go.md` reference file — Go-specific gotchas (`./...` module-boundary behavior, + golangci-lint's home-directory config fallback, `go mod tidy -diff`'s Go 1.23+ requirement). +- `docs/conventions/ecosystem-commands/examples/go.yaml` worked-example fixture. +- `go`/`golang` added to `/toolchain:check` and `/toolchain:lint`'s covered-ecosystem lists and + alias tables. + ## [0.7.0] ### Added diff --git a/plugins/toolchain/reference/ecosystems/go.yaml b/plugins/toolchain/reference/ecosystems/go.yaml new file mode 100644 index 000000000..ad7efa790 --- /dev/null +++ b/plugins/toolchain/reference/ecosystems/go.yaml @@ -0,0 +1,45 @@ +# Bundled portable default — go. Rung-4 fallback ONLY (a consuming repo's +# .claude/ecosystems/go.yaml overrides this key-by-key; this file is never +# written into a consumer repo). Ecosystem-commands contract + schema: +# https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/ecosystem-commands/README.md +# +# project-discovery: a root go.mod bounds `./...` package-pattern expansion — +# verified empirically (Go 1.26.5) that go build/test/list ./... from a repo +# root silently skip a nested module's packages (a nested go.mod, or even a +# root go.work listing both modules, does not cross that boundary for `...` +# expansion). Without walking to each go.mod, a monorepo with a nested Go +# module gets silent incomplete build/test/lint. +# +# go-mod-tidy-drift trigger-globs includes *.go, not just go.mod/go.sum: +# `go mod tidy` can flag drift from a source-only edit (e.g. removing the +# last usage of a dependency leaves go.mod over-declared) that `go build`/ +# `go test` do not fail on, so scoping the gate to go.mod/go.sum changes +# alone would miss exactly the CI/local-parity gap this ecosystem entry +# exists to close. +# +# opt-in rationale: golangci-lint v2 with NO config file present still runs +# its own fixed "standard" linter preset (linters.default: standard) rather +# than erroring or running nothing — i.e. it imposes an opinionated +# diagnostic set nobody explicitly chose, the same risk class the dotnet +# ecosystem's opt-in gate addresses for unconfigured Roslyn formatting +# defaults, and consistent with python's ruff gate below. Note: unlike +# dotnet's EditorConfig discovery, golangci-lint's own config search has no +# root-boundary marker — it walks from the target up to the filesystem root +# and then falls back to the user's home directory, so a stray +# ~/.golangci.yml can make a local run diverge from a clean CI container +# (documented in context/go.md, not addressed by this opt-in gate, which only +# ceilings the *this-plugin's* opt-in check at the repo root). +globs: ["*.go", "go.mod", "go.sum"] +project-discovery: ["go.mod"] +build-cmd: "go build ./..." +test-cmd: "go test ./..." +check-cmd: "golangci-lint run ./..." +fix-cmd: "golangci-lint run --fix ./..." +opt-in: ".golangci.yml, .golangci.yaml, .golangci.toml, or .golangci.json present (walked from the changed file up to the repo root) — otherwise golangci-lint applies its own unconfigured \"standard\" linter preset unconditionally" +install-hint: "Install golangci-lint: https://golangci-lint.run/docs/welcome/install/ | Go toolchain: https://go.dev/dl/" +gates: + - name: go-mod-tidy-drift + cmd: "go mod tidy -diff" + trigger-globs: ["go.mod", "go.sum", "*.go"] + remediation: "Run go mod tidy and commit the updated go.mod/go.sum. (go mod tidy -diff requires Go 1.23+; on an older toolchain the gate errors on the unrecognized flag rather than reporting drift.)" +notes: "govulncheck is intentionally not a rung-4 default (per the epic brief's \"optional\" framing) — add it as a consumer-local gate via .claude/ecosystems/go.local.yaml if desired." diff --git a/plugins/toolchain/skills/check/SKILL.md b/plugins/toolchain/skills/check/SKILL.md index 357e1be9f..8c94d022f 100644 --- a/plugins/toolchain/skills/check/SKILL.md +++ b/plugins/toolchain/skills/check/SKILL.md @@ -24,11 +24,11 @@ Detects affected ecosystems from changed files and runs each one's build → tes `$ARGUMENTS` — optional ecosystem filter. If provided, run only that ecosystem. If omitted, auto-detect from changed files. -Available ecosystem filters are the ecosystems `/toolchain:check` covers: `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown` (resolved per the ladder). Common aliases: `ts`/`node` → `typescript`, `shell` → `bash`, `ps`/`pwsh` → `powershell`, `md` → `markdown`. Literal `all` runs every covered ecosystem. The lint-only `yaml` and `cross-cutting` surfaces are **not** run by `/toolchain:check` — use `/toolchain:lint` for those. +Available ecosystem filters are the ecosystems `/toolchain:check` covers: `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go` (resolved per the ladder). Common aliases: `ts`/`node` → `typescript`, `shell` → `bash`, `ps`/`pwsh` → `powershell`, `md` → `markdown`, `golang` → `go`. Literal `all` runs every covered ecosystem. The lint-only `yaml` and `cross-cutting` surfaces are **not** run by `/toolchain:check` — use `/toolchain:lint` for those. ## Ecosystem detection -Each ecosystem declares a list of `globs` that classify changed files into that ecosystem (resolved per the ladder — consumer `.claude/ecosystems/.yaml` when present, else the bundled default). The skill matches `git status --porcelain` output against each covered ecosystem's `globs` to determine which ecosystems are affected. `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`; the lint-only `yaml` and `cross-cutting` surfaces are `/toolchain:lint`'s (in particular `cross-cutting`'s `**` glob is never matched here). +Each ecosystem declares a list of `globs` that classify changed files into that ecosystem (resolved per the ladder — consumer `.claude/ecosystems/.yaml` when present, else the bundled default). The skill matches `git status --porcelain` output against each covered ecosystem's `globs` to determine which ecosystems are affected. `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go`; the lint-only `yaml` and `cross-cutting` surfaces are `/toolchain:lint`'s (in particular `cross-cutting`'s `**` glob is never matched here). For ecosystem-specific gotchas, reference files, and primary-source detail, read the corresponding context file: @@ -38,6 +38,7 @@ For ecosystem-specific gotchas, reference files, and primary-source detail, read - [context/typescript.md](context/typescript.md) — TypeScript compile, test, lint - [context/bash.md](context/bash.md) — ShellCheck, shfmt - [context/powershell.md](context/powershell.md) — PSScriptAnalyzer +- [context/go.md](context/go.md) — Go build, test, lint, module discovery When invoked as a task (`/toolchain:check`), detect from `git status --porcelain`. When referenced by another skill, use the file list that skill provides. @@ -53,7 +54,7 @@ All commands use absolute paths. Never `cd` and lose context. ### 1. Detect ecosystems -If `$ARGUMENTS` specifies an ecosystem, use it. If `all`, run every covered ecosystem. Otherwise, classify changed files from `git status --porcelain` against each covered ecosystem's `globs` (resolved per the ladder; `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`). Skip any ecosystem whose resolved `enabled` is `false` (a consumer opt-out) — excluded even under `all`. +If `$ARGUMENTS` specifies an ecosystem, use it. If `all`, run every covered ecosystem. Otherwise, classify changed files from `git status --porcelain` against each covered ecosystem's `globs` (resolved per the ladder; `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go`). Skip any ecosystem whose resolved `enabled` is `false` (a consumer opt-out) — excluded even under `all`. If the working tree is clean, fall back to the branch diff so checkpoint-committed work still gets classified (the common pre-PR case: every green block was already committed). Resolve the default branch by **detection, not assumption** — never a hardcoded `main`/`master` — and assign it before use: @@ -111,7 +112,7 @@ Tool presence: before each ecosystem runs, verify the tool is on `PATH`. If miss **Opt-in gate (lint phase only)**: before running an ecosystem's `check-cmd`, evaluate its resolved `opt-in` condition (if present) against the repo. Build and test always run regardless of `opt-in` — only the lint phase is gated, since compiling and testing don't depend on style configuration. -This binary gate applies cleanly when `opt-in` describes ONE condition governing the whole `check-cmd` (e.g. dotnet, python): unmet → report the ecosystem's Lint column as `skip (opt-in unmet: )` — visible, not silently omitted — and do not run `check-cmd`. Met → run `check-cmd` normally. +This binary gate applies cleanly when `opt-in` describes ONE condition governing the whole `check-cmd` (e.g. dotnet, python, go): unmet → report the ecosystem's Lint column as `skip (opt-in unmet: )` — visible, not silently omitted — and do not run `check-cmd`. Met → run `check-cmd` normally. When `opt-in` instead describes MULTIPLE independent per-tool conditions bundled into one opaque command string (e.g. bash's `"shellcheck always applies to shell files; shfmt only when .editorconfig declares shell style"`, where `check-cmd` is `shellcheck ... && shfmt -d `), this gate does NOT apply — `check-cmd` is a single opaque string (per the ecosystem-commands contract) with no way to run one sub-tool's portion without the other. Run `check-cmd` as before (unchanged from prior behavior) and report its real output; do not attempt a partial skip. See Gotchas below for the known atomicity limitation this leaves open. diff --git a/plugins/toolchain/skills/check/context/go.md b/plugins/toolchain/skills/check/context/go.md new file mode 100644 index 000000000..49ac8bcf4 --- /dev/null +++ b/plugins/toolchain/skills/check/context/go.md @@ -0,0 +1,56 @@ +# Go Build Commands + +## Build + +```bash +cd "$PROJECT_DIR" && go build ./... +``` + +## Test + +```bash +cd "$PROJECT_DIR" && go test ./... +``` + +## Lint / Format + +Opt-in gated: only runs when a governing `.golangci.yml`/`.golangci.yaml`/`.golangci.toml`/ +`.golangci.json` is present (see the `opt-in` key and its header-comment rationale in +`reference/ecosystems/go.yaml`) — otherwise skipped visibly rather than imposing golangci-lint's +own unconfigured "standard" linter preset on a repo that never configured any. + +```bash +# Check (CI mode — fails on violations) +cd "$PROJECT_DIR" && golangci-lint run ./... + +# Fix +cd "$PROJECT_DIR" && golangci-lint run --fix ./... +``` + +## Gotchas + +- **`./...` is module-bounded, not repo-bounded** — `go build ./...`/`go test ./...`/ + `go list ./...` run from a repo root silently skip a *nested* module's packages. A nested + `go.mod`, or even a root `go.work` file listing both modules, does not cross that boundary for + `./...` expansion (empirically verified, Go 1.26.5). `project-discovery: ["go.mod"]` in + `go.yaml` handles this by walking to each discovered module root — always run build/test/lint + from the module root containing the relevant `go.mod`, not just the repo root. +- **golangci-lint's config discovery has no `root = true`-equivalent stop marker** — unlike + EditorConfig, it walks from the target up to the filesystem root and then falls back to the + user's **home directory**. A stray `~/.golangci.yml` on a developer's machine can make a local + run diverge from a clean CI container that has no such file. This plugin's own `opt-in` gate + only ceilings *its* presence check at the repo root — it does not (and cannot) suppress + golangci-lint's own home-directory fallback once the tool actually runs. +- **`go mod tidy -diff` requires Go 1.23+** — the `go-mod-tidy-drift` gate in `go.yaml` uses this + flag; on an older toolchain it will error rather than report drift. +- **GOFLAGS** — a repo-level `GOFLAGS` env var or `go env -w GOFLAGS=...` setting changes build/test + behavior repo-wide (e.g. `-mod=readonly`); check for one before assuming a bare command failure + is a real break. + +## Project discovery + +Find all Go modules dynamically: + +```bash +find "$REPO_ROOT" -name "go.mod" -not -path "*/vendor/*" +``` diff --git a/plugins/toolchain/skills/check/evals/evals.json b/plugins/toolchain/skills/check/evals/evals.json index 37b1d461f..cf37fe912 100644 --- a/plugins/toolchain/skills/check/evals/evals.json +++ b/plugins/toolchain/skills/check/evals/evals.json @@ -42,10 +42,10 @@ "id": 4, "name": "build-scope-excludes-lint-only-surfaces", "prompt": "/toolchain:check all — the working tree changed a GitHub Actions workflow YAML and a plain README, among other files.", - "expected_output": "Runs only the ecosystems /toolchain:check covers (dotnet, python, typescript, bash, powershell, markdown). It does NOT run the lint-only `yaml` or `cross-cutting` surfaces — those belong to /toolchain:lint, and cross-cutting's `**` glob is never matched here.", + "expected_output": "Runs only the ecosystems /toolchain:check covers (dotnet, python, typescript, bash, powershell, markdown, go). It does NOT run the lint-only `yaml` or `cross-cutting` surfaces — those belong to /toolchain:lint, and cross-cutting's `**` glob is never matched here.", "files": [], "expectations": [ - "Runs only the six ecosystems `/toolchain:check` covers (dotnet, python, typescript, bash, powershell, markdown)", + "Runs only the seven ecosystems `/toolchain:check` covers (dotnet, python, typescript, bash, powershell, markdown, go)", "Does NOT run the `yaml` surface for the workflow file — that surface is `/toolchain:lint`-only", "Does NOT run the `cross-cutting` surface — its `**` glob is never matched by `/toolchain:check` (both `yaml` and `cross-cutting` are `/toolchain:lint`-only surfaces)" ] diff --git a/plugins/toolchain/skills/lint/SKILL.md b/plugins/toolchain/skills/lint/SKILL.md index f31e4c96b..4a93e0bc9 100644 --- a/plugins/toolchain/skills/lint/SKILL.md +++ b/plugins/toolchain/skills/lint/SKILL.md @@ -31,7 +31,7 @@ Use `/toolchain:lint` for quick feedback during development. Use `/verification: **Ecosystem filters** (if omitted, auto-detect from changed files): -`/toolchain:lint` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `yaml`, and `cross-cutting` (each resolved per the ladder); any with matching files is exposed as a filter. Aliases: `py` → `python`; `ts`/`node` → `typescript`; `shell` → `bash`; `ps`/`pwsh` → `powershell`; `md` → `markdown`; `xc`/`text` → `cross-cutting`. Literal `all` runs every applicable ecosystem. +`/toolchain:lint` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go`, `yaml`, and `cross-cutting` (each resolved per the ladder); any with matching files is exposed as a filter. Aliases: `py` → `python`; `ts`/`node` → `typescript`; `shell` → `bash`; `ps`/`pwsh` → `powershell`; `md` → `markdown`; `golang` → `go`; `xc`/`text` → `cross-cutting`. Literal `all` runs every applicable ecosystem. **Mode flag:** @@ -100,6 +100,7 @@ Per-project walking (ecosystems with `project-discovery`): - python: walk each `pyproject.toml` directory and run check/fix from there - typescript: walk each `package.json` directory and run check/fix from there +- go: walk each `go.mod` directory and run check/fix from there (a root `./...` invocation stops at a nested module boundary — see `/toolchain:check`'s per-ecosystem context file) Tool presence: verify tools on `PATH` before each ecosystem; report `skip` with `install-hint` when missing.