From c2f5e1ab7061127520a9eef8d9e3f5dc3a0e998f Mon Sep 17 00:00:00 2001 From: John <11795372+jigarkhwar@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:04:50 +0400 Subject: [PATCH] chore(process): add the feature milestones rule and the gate files Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/linkage-guard.sh | 103 ++++++++ .claude/hooks/linkage-guard_test.sh | 107 +++++++++ .githooks/pre-push | 68 ++++++ .githooks/pre-push_test.sh | 136 +++++++++++ AGENTS.md | 11 + cspell.json | 1 + scripts/check-linkage.sh | 260 +++++++++++++++++++++ scripts/check-linkage_test.sh | 348 ++++++++++++++++++++++++++++ 8 files changed, 1034 insertions(+) create mode 100755 .claude/hooks/linkage-guard.sh create mode 100755 .claude/hooks/linkage-guard_test.sh create mode 100755 .githooks/pre-push create mode 100755 .githooks/pre-push_test.sh create mode 100755 scripts/check-linkage.sh create mode 100755 scripts/check-linkage_test.sh diff --git a/.claude/hooks/linkage-guard.sh b/.claude/hooks/linkage-guard.sh new file mode 100755 index 0000000..3141b9f --- /dev/null +++ b/.claude/hooks/linkage-guard.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) guard — gate ONLY release tagging. ~0 tokens. Normal push/merge untouched. +# Fires ONLY on a real git tag/push command, never on text that merely mentions one. +# Bypass a deliberate release: LINKAGE_OFF=1 +# +# This is the source every Galaxio repository copies. Change it here and carry the +# change outward; linkage-guard_test.sh beside it is the contract those copies +# share. It reached this shape after two repositories hit the same failure +# independently — a `gh pr create` whose body documented the hook was blocked by +# it, reporting a version that came from prose. +# +# One divergence, carried back the other way: the push rule does not treat a +# release/N branch as a release. Publishing is what the tag does, a branch push +# publishes nothing, and the version was being read out of the branch name — but +# a release branch serves a whole minor line (release/1.2.0 -> v1.2.0, v1.2.1, …), +# so for every patch release that name is the wrong version and the guard went +# looking for a milestone that never existed. It blocked the documented release +# procedure at its second step. +# +# This is the fast in-session failure, not the whole gate: it only ever sees +# commands an agent runs, so a push from a terminal or an IDE never reaches it. +# .githooks/pre-push catches those by reading the refs git hands it. Both run +# locally and can be skipped; nothing checks server-side unless the project's +# release workflow runs scripts/check-linkage.sh --for-tag itself. +set -uo pipefail +input=$(cat) +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true) +[ -n "$cmd" ] || exit 0 +[ "${LINKAGE_OFF:-}" = "1" ] && exit 0 +case "$cmd" in *LINKAGE_OFF=1*) exit 0 ;; esac +case "$cmd" in *git*) ;; *) exit 0 ;; esac +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +checker="$root/scripts/check-linkage.sh" +block() { printf 'BLOCKED by linkage-guard:\n%s\n' "$1" >&2; exit 2; } + +# Scope the guard to what the shell would actually RUN. Matching the raw command string +# made any *argument* mentioning a tag look like a release — a `gh pr create` whose heredoc +# body documented this hook was blocked, reporting a version that came from prose. +# So: drop heredoc bodies, split on shell separators, keep only segments that invoke git. +strip_heredocs() { + local line trimmed term="" re='<<-?[[:space:]]*["'"'"']?([A-Za-z_][A-Za-z0-9_]*)["'"'"']?' + while IFS= read -r line; do + if [ -n "$term" ]; then + trimmed="${line#"${line%%[![:space:]]*}"}" + [ "$trimmed" = "$term" ] && term="" + continue + fi + printf '%s\n' "$line" + [[ $line =~ $re ]] && term="${BASH_REMATCH[1]}" + done +} + +split_segments() { + local s="$1" + s="${s//&&/$'\n'}"; s="${s//||/$'\n'}"; s="${s//;/$'\n'}"; s="${s//|/$'\n'}" + printf '%s\n' "$s" +} + +# Peel leading env assignments and wrappers so `rtk proxy git tag …` still counts as git. +peel() { + local s="$1" head + s="${s#"${s%%[![:space:]]*}"}" + while :; do + head="${s%% *}" + case "$head" in + [A-Za-z_]*=*) s="${s#"$head"}" ;; + rtk|proxy|sudo|env|nohup|time|command|builtin|exec|xargs) s="${s#"$head"}" ;; + *) break ;; + esac + s="${s#"${s%%[![:space:]]*}"}" + done + printf '%s' "$s" +} + +is_tag=0 +hit="" +segments=$(split_segments "$(printf '%s\n' "$cmd" | strip_heredocs)") +while IFS= read -r seg; do + seg=$(peel "$seg") + case "$seg" in git|git\ *) ;; *) continue ;; esac + # Non-release subcommands are out of scope. Scoped to THIS segment: the old global + # reset let `git commit -m … && git tag vX.Y.Z` through ungated. + printf '%s' "$seg" | grep -qE '^git[[:space:]]+(commit|log|show)\b' && continue + # Any `git tag …` carrying a vX.Y.Z, in any flag order — upstream required the version + # to sit immediately after `tag`, so the documented annotated form (`git tag -a vX.Y.Z -m …`) + # slipped through. Read-only/destructive subcommands (-l/--list, -d/--delete, -v/--verify, + # -n) are not release creation and stay out of scope. + if printf '%s' "$seg" | grep -qE '^git[[:space:]]+tag\b' \ + && printf '%s' "$seg" | grep -qE '\bv?[0-9]+\.[0-9]+\.[0-9]+' \ + && ! printf '%s' "$seg" | grep -qE '^git[[:space:]]+tag[[:space:]]+(-l\b|--list\b|-d\b|--delete\b|-v\b|--verify\b|-n)'; then + is_tag=1; hit="$seg"; break + fi + if printf '%s' "$seg" | grep -qE '^git[[:space:]]+push\b' \ + && printf '%s' "$seg" | grep -qE '(--tags|refs/tags/|[[:space:]](origin[[:space:]]+)?v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$))'; then + is_tag=1; hit="$seg"; break + fi +done <<<"$segments" +[ "$is_tag" = 1 ] || exit 0 +[ -x "$checker" ] || block "checker missing ($checker) — release cannot be verified" +ver=$(printf '%s' "$hit" | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1) +[ -n "$ver" ] || block "tag/release push without explicit vX.Y.Z — verify: scripts/check-linkage.sh --for-tag " +out=$("$checker" --for-tag "$ver" 2>&1) || block "$out" +exit 0 diff --git a/.claude/hooks/linkage-guard_test.sh b/.claude/hooks/linkage-guard_test.sh new file mode 100755 index 0000000..ea730c4 --- /dev/null +++ b/.claude/hooks/linkage-guard_test.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# +# linkage-guard_test.sh — regression suite for .claude/hooks/linkage-guard.sh. +# +# This suite travels with the guard. It is the contract the copies in every +# Galaxio repository share, and each case below is a shape the guard got wrong +# at some point — keep them, and add to them rather than trimming. +# +# The guard must gate release tagging and nothing else. Two symmetric failures matter: +# blocking a command that merely *mentions* tagging, and waving through a real tag hidden +# in a compound command. Both are covered below. +# +# The real scripts/check-linkage.sh is replaced by a stub, so the suite is deterministic, +# offline, and independent of milestone state. It asserts only the guard's scope decision: +# exit 0 = passed through, exit 2 = gated. +# +# Usage: bash scripts/test-linkage-guard.sh [GUARD=/path/to/linkage-guard.sh] +# Exit: 0 all green | 1 at least one case failed +set -uo pipefail + +# The suite sits beside the hook it tests, as .githooks/pre-push_test.sh does. +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GUARD="${GUARD:-$here/linkage-guard.sh}" +[ -r "$GUARD" ] || { echo "guard not found: $GUARD" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; } + +fake=$(mktemp -d) +trap 'rm -rf "$fake"' EXIT +mkdir -p "$fake/scripts" +cat >"$fake/scripts/check-linkage.sh" <<'STUB' +#!/usr/bin/env bash +echo "stub check-linkage: $*" +exit 1 +STUB +chmod +x "$fake/scripts/check-linkage.sh" + +pass=0 +fail=0 + +run() { # run