feat(hooks): require-plan по репозиторию правимого файла (#6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
87f2c956aa
commit
1620d1e806
@ -1,33 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# PreToolUse hook: edits in code dirs (any src/ by default, or .claude/code-paths) require a plan for this branch or a .bounded marker.
|
# PreToolUse hook (Edit/Write/MultiEdit): правки в каталогах кода (любой src/ или .claude/code-paths) — только при
|
||||||
set -euo pipefail
|
# плане для ветки или файле .bounded. Репозиторий — тот, где лежит файл; логика в harness_target.py рядом.
|
||||||
input=$(cat)
|
py="${0%/*}/harness_target.py"
|
||||||
read -r cwd fpath < <(printf '%s' "$input" | python3 -c '
|
command -v python3 >/dev/null 2>&1 && [ -f "$py" ] || exit 0
|
||||||
import sys,json
|
python3 "$py" require-plan
|
||||||
d=json.load(sys.stdin); ti=d.get("tool_input",{}) or {}
|
[ $? -eq 2 ] && exit 2
|
||||||
print(d.get("cwd",""), ti.get("file_path") or ti.get("path") or "")' 2>/dev/null || echo " ")
|
exit 0
|
||||||
[ -n "$cwd" ] && [ -n "$fpath" ] || exit 0
|
|
||||||
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
|
|
||||||
[ -n "$root" ] || exit 0
|
|
||||||
# Защищаемые пути. По умолчанию — любой каталог src/ на любой глубине (src/, api/src/, packages/*/src/).
|
|
||||||
# Проект может переопределить списком префиксов (относительно корня, по одному на строку) в .claude/code-paths.
|
|
||||||
rel=${fpath#"$root"/}
|
|
||||||
protected=0
|
|
||||||
if [ -f "$root/.claude/code-paths" ]; then
|
|
||||||
while IFS= read -r p; do
|
|
||||||
p=${p%%#*}; p=${p## }; p=${p%% }; [ -n "$p" ] || continue
|
|
||||||
case "$rel" in "${p%/}"/*) protected=1; break ;; esac
|
|
||||||
done < "$root/.claude/code-paths"
|
|
||||||
else
|
|
||||||
case "/$rel" in */src/*) protected=1 ;; esac
|
|
||||||
fi
|
|
||||||
[ "$protected" = 1 ] || exit 0
|
|
||||||
[ -f "$root/.bounded" ] && exit 0
|
|
||||||
branch=$(git -C "$cwd" branch --show-current 2>/dev/null || true)
|
|
||||||
if ls "$root"/docs/superpowers/plans/*.md >/dev/null 2>&1; then
|
|
||||||
if [ -z "$branch" ] || grep -lq -- "$branch" "$root"/docs/superpowers/plans/*.md 2>/dev/null; then exit 0; fi
|
|
||||||
# any plan exists but none names this branch: still allow if only one plan (single-feature repo)
|
|
||||||
[ "$(ls "$root"/docs/superpowers/plans/*.md | wc -l)" -eq 1 ] && exit 0
|
|
||||||
fi
|
|
||||||
echo "BLOCKED: editing $fpath without a plan. Run superpowers:brainstorming; for a bounded change create '$root/.bounded' first." >&2
|
|
||||||
exit 2
|
|
||||||
|
|||||||
34
tests/test-hook-require-plan.sh
Normal file
34
tests/test-hook-require-plan.sh
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# require-plan.sh: корень, ветка, code-paths, .bounded и планы — из репозитория правимого файла (спека hook-guards).
|
||||||
|
H="$ROOT/hooks/require-plan.sh"
|
||||||
|
S="$TMP/rp"; mkdir -p "$S/out"
|
||||||
|
mk(){ git init -q "$1" && git -C "$1" symbolic-ref HEAD "refs/heads/${2:-main}" && git -C "$1" commit -q --allow-empty -m init; }
|
||||||
|
mk "$S/a"; touch "$S/a/.bounded" # A: есть .bounded
|
||||||
|
mk "$S/b" # B: ни плана, ни .bounded
|
||||||
|
mk "$S/f"; git -C "$S/f" worktree add -q "$S/wt" -b feat/6-x; touch "$S/wt/.bounded"
|
||||||
|
mk "$S/c"; mkdir -p "$S/c/.claude"; printf 'hooks/ # код\n\nskills/\n' > "$S/c/.claude/code-paths"
|
||||||
|
mk "$S/p2" feat/6-x; mkdir -p "$S/p2/docs/superpowers/plans"
|
||||||
|
echo "Ветка: feat/6-x" > "$S/p2/docs/superpowers/plans/2026-10-16-a.md"; echo "другое" > "$S/p2/docs/superpowers/plans/2026-10-11-b.md"
|
||||||
|
mk "$S/p0" feat/7-y; mkdir -p "$S/p0/docs/superpowers/plans"
|
||||||
|
echo "Ветка: feat/6-x" > "$S/p0/docs/superpowers/plans/a.md"; echo "другое" > "$S/p0/docs/superpowers/plans/b.md"
|
||||||
|
mk "$S/p1" feat/7-y; mkdir -p "$S/p1/docs/superpowers/plans"; echo "x" > "$S/p1/docs/superpowers/plans/a.md"
|
||||||
|
mk "$S/pd"; mkdir -p "$S/pd/docs/superpowers/plans"; echo a > "$S/pd/docs/superpowers/plans/a.md"; echo b > "$S/pd/docs/superpowers/plans/b.md"
|
||||||
|
git -C "$S/pd" checkout -q --detach
|
||||||
|
j(){ python3 -c 'import json,sys; print(json.dumps({"cwd":sys.argv[1],"tool_name":"Edit","tool_input":{"file_path":sys.argv[2]}}))' "$@"; }
|
||||||
|
rc(){ j "$@" | bash "$H" 2>/dev/null; echo $?; }
|
||||||
|
assert_eq "файл в B без плана при cwd в A с .bounded" "$(rc "$S/a" "$S/b/src/a.py")" 2
|
||||||
|
assert_eq "файл в worktree с .bounded при cwd на main" "$(rc "$S/f" "$S/wt/src/a.py")" 0
|
||||||
|
assert_eq "src на глубине без плана" "$(rc "$S/b" "$S/b/api/src/a.py")" 2
|
||||||
|
assert_eq "не код — пропуск" "$(rc "$S/b" "$S/b/docs/a.md")" 0
|
||||||
|
assert_eq "вне репозитория — пропуск" "$(rc "$S/b" "$S/out/src/a.py")" 0
|
||||||
|
assert_eq "code-paths: hooks/ без плана" "$(rc "$S/a" "$S/c/hooks/x.sh")" 2
|
||||||
|
assert_eq "code-paths: src/ не защищён" "$(rc "$S/c" "$S/c/src/a.py")" 0
|
||||||
|
assert_eq "два плана, один с веткой" "$(rc "$S/p2" "$S/p2/src/a.py")" 0
|
||||||
|
assert_eq "два плана без ветки" "$(rc "$S/p0" "$S/p0/src/a.py")" 2
|
||||||
|
assert_eq "один план — пропуск" "$(rc "$S/p1" "$S/p1/src/a.py")" 0
|
||||||
|
assert_eq "detached HEAD при планах — пропуск" "$(rc "$S/pd" "$S/pd/src/a.py")" 0
|
||||||
|
assert_eq "не JSON — пропуск" "$(printf 'not json' | bash "$H" 2>/dev/null; echo $?)" 0
|
||||||
|
assert_eq "без file_path — пропуск" "$(printf '{"cwd":"%s","tool_input":{}}' "$S/b" | bash "$H" 2>/dev/null; echo $?)" 0
|
||||||
|
m=$(j "$S/a" "$S/b/src/a.py" | bash "$H" 2>&1 >/dev/null)
|
||||||
|
assert_grep "сообщение: префикс и путь" '^BLOCKED \[require-plan\]: правка src/a.py' <(printf '%s' "$m")
|
||||||
|
assert_grep "сообщение: .bounded репозитория файла" "$(basename "$S")/b/\.bounded" <(printf '%s' "$m")
|
||||||
|
assert_grep "settings.hooks.json регистрирует require-plan" 'require-plan\.sh' "$ROOT/hooks/settings.hooks.json"
|
||||||
Loading…
x
Reference in New Issue
Block a user