661 lines
26 KiB
Python
661 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""claude-harness: общее ядро хуков guard-main и require-plan — решение по цели записи, а не по cwd.
|
||
|
||
Обёртки hooks/guard-main.sh и hooks/require-plan.sh передают сюда JSON вызова из stdin:
|
||
python3 harness_target.py guard-main|require-plan < hook.json → exit 0 (пропустить) | 2 (блок, причина в stderr)
|
||
Отладка разбора без файловой системы:
|
||
python3 harness_target.py targets '<команда>' [cwd] → по строке на цель: «kind path op»
|
||
Любой сбой разбора или вызова git — пропуск (exit 0). Только stdlib, Python 3.8+.
|
||
Спека: openspec/changes/hooks-write-target/specs/hook-guards/spec.md.
|
||
"""
|
||
import glob
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from collections import namedtuple
|
||
|
||
# kind: file | git | switch; path: абсолютный путь или None (не разрешился); op: что делает команда.
|
||
# Для switch path — каталог репозитория, op — новая ветка (None — не определена).
|
||
Target = namedtuple("Target", "kind path op")
|
||
|
||
PROTECTED = ("main", "master", "") # "" — detached HEAD
|
||
SEPS = (";", "&&", "||", "|", "|&", "&", ";;", "\n", "(", ")")
|
||
OPS = sorted(["&&", "||", ";;", ";", "|&", "|", "&>>", "&>", "&", "(", ")", ">>", ">|", ">&", ">",
|
||
"<<<", "<<-", "<<", "<&", "<>", "<"], key=len, reverse=True)
|
||
OP_CHARS = set(";&|()<>")
|
||
WRITE_REDIRS = (">", ">>", ">|", "&>", "&>>")
|
||
KEYWORDS = ("if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}")
|
||
SKIP_HEADS = ("for", "case", "select", "function")
|
||
PREFIXES = ("sudo", "env", "nohup", "command", "exec", "nice", "time", "builtin", "xargs")
|
||
ALL_PATHS = ("tee", "touch", "mkdir", "rm", "rmdir", "truncate", "unlink", "mv", "shred")
|
||
DEST = ("cp", "install", "ln", "rsync")
|
||
GIT_MUT = ("commit", "merge", "rebase", "cherry-pick", "revert", "reset", "am", "add", "rm", "mv", "restore")
|
||
ASSIGN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^]]*\])?\+?=")
|
||
FD = re.compile(r"\d+(?=[<>])")
|
||
VAR = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|[0-9?$!#*@-]")
|
||
|
||
|
||
def _home():
|
||
return os.environ.get("HOME", "")
|
||
|
||
|
||
# ---------- лексер ----------
|
||
|
||
class Lexer:
|
||
"""Токены команды: ("w", текст, динамическое?) и ("op", оператор). Тело heredoc пропускается.
|
||
|
||
Динамическое слово содержит $VAR, $(…) или `…` — в тексте остаётся сырой вид подстановки.
|
||
~, $HOME и ${HOME} раскрываются по переменной окружения HOME.
|
||
"""
|
||
|
||
def __init__(self, s, i=0):
|
||
self.s, self.i, self.heredocs = s, i, []
|
||
|
||
def tokens(self, until_paren=False):
|
||
s, out, depth = self.s, [], 0
|
||
while self.i < len(s):
|
||
c = s[self.i]
|
||
if c in " \t":
|
||
self.i += 1
|
||
elif s.startswith("\\\n", self.i):
|
||
self.i += 2
|
||
elif c == "\n":
|
||
self.i += 1
|
||
out.append(("op", "\n"))
|
||
self._skip_heredocs()
|
||
elif c == "#":
|
||
j = s.find("\n", self.i)
|
||
self.i = len(s) if j < 0 else j
|
||
elif FD.match(s, self.i):
|
||
m = FD.match(s, self.i)
|
||
self.i = m.end()
|
||
op = self._op()
|
||
out.append(("op", m.group() + op))
|
||
elif c in OP_CHARS:
|
||
op = self._op()
|
||
if until_paren and op == "(":
|
||
depth += 1
|
||
elif until_paren and op == ")":
|
||
if depth == 0:
|
||
return out
|
||
depth -= 1
|
||
out.append(("op", op))
|
||
if op in ("<<", "<<-"):
|
||
w = self._word()
|
||
if w is not None:
|
||
self.heredocs.append((w[0], op == "<<-"))
|
||
out.append(("w",) + w)
|
||
else:
|
||
out.append(("w",) + self._word())
|
||
if until_paren:
|
||
raise ValueError("unclosed $(")
|
||
return out
|
||
|
||
def _op(self):
|
||
op = next(o for o in OPS if self.s.startswith(o, self.i))
|
||
self.i += len(op)
|
||
return op
|
||
|
||
def _skip_heredocs(self):
|
||
s = self.s
|
||
for delim, strip_tabs in self.heredocs:
|
||
while self.i < len(s):
|
||
j = s.find("\n", self.i)
|
||
line = s[self.i:len(s) if j < 0 else j]
|
||
self.i = len(s) if j < 0 else j + 1
|
||
if (line.lstrip("\t") if strip_tabs else line) == delim:
|
||
break
|
||
self.heredocs = []
|
||
|
||
def _word(self):
|
||
s, n = self.s, len(self.s)
|
||
while self.i < n and s[self.i] in " \t":
|
||
self.i += 1
|
||
start, buf, dyn = self.i, [], False
|
||
while self.i < n:
|
||
c = s[self.i]
|
||
if c in " \t\n" or c in OP_CHARS:
|
||
break
|
||
if c == "\\":
|
||
if self.i + 1 < n and s[self.i + 1] != "\n":
|
||
buf.append(s[self.i + 1])
|
||
self.i += 2
|
||
elif c == "'":
|
||
j = s.find("'", self.i + 1)
|
||
if j < 0:
|
||
raise ValueError("unclosed '")
|
||
buf.append(s[self.i + 1:j])
|
||
self.i = j + 1
|
||
elif c == '"':
|
||
self.i += 1
|
||
while True:
|
||
if self.i >= n:
|
||
raise ValueError('unclosed "')
|
||
c = s[self.i]
|
||
if c == '"':
|
||
self.i += 1
|
||
break
|
||
if c == "\\" and self.i + 1 < n and s[self.i + 1] in '"\\$`\n':
|
||
if s[self.i + 1] != "\n":
|
||
buf.append(s[self.i + 1])
|
||
self.i += 2
|
||
elif c in "$`":
|
||
t, d = self._dollar()
|
||
buf.append(t)
|
||
dyn = dyn or d
|
||
else:
|
||
buf.append(c)
|
||
self.i += 1
|
||
elif c in "$`":
|
||
t, d = self._dollar()
|
||
buf.append(t)
|
||
dyn = dyn or d
|
||
elif c == "~" and self.i == start and (self.i + 1 >= n or s[self.i + 1] in "/ \t\n;&|()<>"):
|
||
buf.append(_home())
|
||
self.i += 1
|
||
else:
|
||
buf.append(c)
|
||
self.i += 1
|
||
if self.i == start:
|
||
return None
|
||
return "".join(buf), dyn
|
||
|
||
def _dollar(self):
|
||
"""$…/`…` с позиции i → (текст, динамическое?)."""
|
||
s, i = self.s, self.i
|
||
if s[i] == "`":
|
||
j = i + 1
|
||
while j < len(s) and s[j] != "`":
|
||
j += 2 if s[j] == "\\" else 1
|
||
if j >= len(s):
|
||
raise ValueError("unclosed `")
|
||
self.i = j + 1
|
||
return s[i:j + 1], True
|
||
if s.startswith("$(", i): # подстановка и арифметика $((…)): пропуск до парной скобки
|
||
sub = Lexer(s, i + 2)
|
||
sub.tokens(until_paren=True)
|
||
self.i = sub.i
|
||
return s[i:self.i], True
|
||
if s.startswith("${", i):
|
||
j = s.find("}", i)
|
||
if j < 0:
|
||
raise ValueError("unclosed ${")
|
||
self.i = j + 1
|
||
return (_home(), False) if s[i + 2:j] == "HOME" else (s[i:j + 1], True)
|
||
m = VAR.match(s, i + 1)
|
||
if not m:
|
||
self.i = i + 1
|
||
return "$", False
|
||
self.i = m.end()
|
||
return (_home(), False) if m.group() == "HOME" else (s[i:m.end()], True)
|
||
|
||
|
||
# ---------- разбор ----------
|
||
|
||
def _join(cur, p):
|
||
"""Абсолютный нормализованный путь; у glob — каталог до первого метасимвола; None, если не определить."""
|
||
if p is None:
|
||
return None
|
||
m = re.search(r"[*?\[]", p)
|
||
if m:
|
||
p = os.path.dirname(p[:m.start()]) or "."
|
||
if os.path.isabs(p):
|
||
return os.path.normpath(p)
|
||
if not cur:
|
||
return None
|
||
return os.path.normpath(os.path.join(cur, p))
|
||
|
||
|
||
def _path(w, cur):
|
||
text, dyn = w
|
||
return None if dyn else _join(cur, text)
|
||
|
||
|
||
def _operands(args, valued=()):
|
||
"""Аргументы-операнды без опций; опции из valued забирают следующее слово как значение."""
|
||
out, k, opts_done = [], 0, False
|
||
while k < len(args):
|
||
t = args[k][0]
|
||
if not opts_done and t == "--":
|
||
opts_done = True
|
||
elif not opts_done and t.startswith("-") and t != "-":
|
||
if t in valued:
|
||
k += 1
|
||
else:
|
||
out.append(args[k])
|
||
k += 1
|
||
return out
|
||
|
||
|
||
def _branch(w):
|
||
"""Имя ветки из слова. Для динамического — '<статический префикс>*', если префикс не может стать
|
||
защищённой веткой, иначе None (не определена)."""
|
||
text, dyn = w
|
||
if not dyn:
|
||
return text
|
||
cut = min([i for i in (text.find("$"), text.find("`")) if i >= 0] or [len(text)])
|
||
prefix = text[:cut]
|
||
if prefix and not any(p.startswith(prefix) for p in ("main", "master", "HEAD", "refs/heads/main",
|
||
"refs/heads/master")):
|
||
return prefix + "*"
|
||
return None
|
||
|
||
|
||
class _State:
|
||
def __init__(self, cwd, out, depth):
|
||
self.cur, self.out, self.depth, self.dirs = cwd, out, depth, []
|
||
|
||
|
||
def parse(cmd, cwd, _out=None, _depth=0):
|
||
"""Цели записи команды Bash в порядке выполнения. Чистая функция: файловую систему не читает.
|
||
cwd — каталог, от которого считаются относительные пути (None — неизвестен)."""
|
||
out = [] if _out is None else _out
|
||
if _depth > 5:
|
||
return out
|
||
st = _State(cwd or None, out, _depth)
|
||
toks = Lexer(cmd).tokens()
|
||
stack, words, redirs, prev = [], [], [], None
|
||
i = 0
|
||
while i <= len(toks):
|
||
t = toks[i] if i < len(toks) else ("op", "\n")
|
||
if t[0] == "w":
|
||
words.append((t[1], t[2]))
|
||
i += 1
|
||
continue
|
||
op = t[1]
|
||
if op in SEPS:
|
||
if words or redirs:
|
||
new = _simple(words, redirs, st)
|
||
if new is not _SAME and op not in ("|", "|&") and prev not in ("|", "|&"):
|
||
st.cur = new
|
||
words, redirs = [], []
|
||
if op == "(":
|
||
stack.append(st.cur)
|
||
elif op == ")" and stack:
|
||
st.cur = stack.pop()
|
||
prev = op
|
||
i += 1
|
||
continue
|
||
nxt = toks[i + 1] if i + 1 < len(toks) and toks[i + 1][0] == "w" else None
|
||
redirs.append((op, (nxt[1], nxt[2]) if nxt else None))
|
||
i += 2 if nxt else 1
|
||
return out
|
||
|
||
|
||
_SAME = object()
|
||
|
||
|
||
def _simple(words, redirs, st):
|
||
"""Одна простая команда: цели в st.out; возвращает новый каталог для cd/pushd/popd или _SAME."""
|
||
cur, out = st.cur, st.out
|
||
for op, w in redirs:
|
||
o = op.lstrip("0123456789")
|
||
if w is None:
|
||
continue
|
||
if o in WRITE_REDIRS or (o == ">&" and not re.fullmatch(r"\d+-?|-", w[0])):
|
||
if not w[1] and w[0].startswith("/dev/"):
|
||
continue
|
||
out.append(Target("file", _path(w, cur), op))
|
||
ws = list(words)
|
||
while ws and ws[0][0] in KEYWORDS and not ws[0][1]:
|
||
ws.pop(0)
|
||
if not ws or ws[0][0] in SKIP_HEADS:
|
||
return _SAME
|
||
xargs = False
|
||
while ws:
|
||
h = ws[0][0]
|
||
if ASSIGN.match(h):
|
||
ws.pop(0)
|
||
elif h in PREFIXES and not ws[0][1]:
|
||
xargs = xargs or h == "xargs"
|
||
ws.pop(0)
|
||
while ws and (ws[0][0].startswith("-") or (h == "env" and ASSIGN.match(ws[0][0]))):
|
||
ws.pop(0)
|
||
else:
|
||
break
|
||
if not ws or ws[0][1]:
|
||
return _SAME # пусто или голова — переменная: неизвестная команда
|
||
head, args = os.path.basename(ws[0][0]), ws[1:]
|
||
|
||
def file_(w, op):
|
||
out.append(Target("file", None if xargs else _path(w, cur), op))
|
||
|
||
if head in ("cd", "pushd"):
|
||
a = _operands(args)
|
||
if not a:
|
||
new = _home() or None
|
||
else:
|
||
new = None if a[0][1] or a[0][0] == "-" else _join(cur, a[0][0])
|
||
if head == "pushd":
|
||
st.dirs.append(cur)
|
||
return new
|
||
if head == "popd":
|
||
return st.dirs.pop() if st.dirs else None
|
||
if head in ("sh", "bash", "zsh", "dash"):
|
||
for k, (t, d) in enumerate(args):
|
||
if t.startswith("-") and not t.startswith("--") and "c" in t[1:]:
|
||
if k + 1 < len(args) and not args[k + 1][1]:
|
||
parse(args[k + 1][0], cur, out, st.depth + 1)
|
||
break
|
||
return _SAME
|
||
if head == "eval":
|
||
parse(" ".join(t for t, d in args), cur, out, st.depth + 1)
|
||
return _SAME
|
||
if xargs and head in ALL_PATHS + DEST + ("chmod", "chown", "chgrp", "sed", "perl"):
|
||
out.append(Target("file", None, head)) # пути придут из stdin
|
||
return _SAME
|
||
if head in ALL_PATHS:
|
||
for w in _operands(args, ("-s", "--size") if head == "truncate" else ()):
|
||
file_(w, head)
|
||
elif head in ("chmod", "chown", "chgrp"):
|
||
for w in _operands(args)[1:]:
|
||
file_(w, head)
|
||
elif head in DEST:
|
||
tdir = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-t"), None)
|
||
tdir = tdir or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--target-directory=")), None)
|
||
ops = _operands(args, ("-t", "-S", "--suffix"))
|
||
if tdir:
|
||
dst = tdir
|
||
elif len(ops) >= 2:
|
||
dst = ops[-1]
|
||
elif head == "ln" and ops:
|
||
dst = (".", False) # ln -s цель — ссылка в текущем каталоге
|
||
else:
|
||
dst = None
|
||
if dst is not None and not (head == "rsync" and re.match(r"[^/]*:", dst[0])):
|
||
file_(dst, head)
|
||
elif head == "sed":
|
||
inplace = any(t == "-i" or t.startswith("-i") or t.startswith("--in-place")
|
||
or re.fullmatch(r"-[A-Za-z]*i[A-Za-z]*", t) for t, d in args)
|
||
if inplace:
|
||
rest = [w for k, w in enumerate(args)
|
||
if not (w[0] == "" and k > 0 and args[k - 1][0] == "-i")] # BSD: sed -i '' …
|
||
has_script = any(t in ("-e", "-f", "--expression", "--file") or t.startswith("--expression=")
|
||
for t, d in rest)
|
||
ops = _operands(rest, ("-e", "-f", "-l", "--expression", "--file"))
|
||
for w in (ops if has_script else ops[1:]):
|
||
file_(w, "sed -i")
|
||
elif head == "perl":
|
||
if any(re.fullmatch(r"-[A-Za-z]*i\S*", t) for t, d in args):
|
||
has_script = any(re.fullmatch(r"-[A-Za-z]*[eE]", t) for t, d in args)
|
||
ops = _operands(args, ("-e", "-E", "-pe", "-ne", "-pie", "-npe"))
|
||
for w in (ops if has_script else ops[1:]):
|
||
file_(w, "perl -i")
|
||
elif head == "dd":
|
||
for t, d in args:
|
||
if t.startswith("of="):
|
||
file_((t[3:], d), "dd")
|
||
elif head == "tar":
|
||
first = args[0][0] if args else ""
|
||
if any(t in ("--extract", "--get") or (re.fullmatch(r"-[A-Za-z]+", t) and "x" in t) for t, d in args) \
|
||
or (re.fullmatch(r"[A-Za-z]+", first) and "x" in first):
|
||
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-C"), None)
|
||
dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None)
|
||
file_(dst or (".", False), "tar -x")
|
||
elif head == "unzip":
|
||
if not any(t in ("-l", "-t", "-v", "-Z", "-p") for t, d in args):
|
||
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None)
|
||
file_(dst or (".", False), "unzip")
|
||
elif head == "patch":
|
||
if "--dry-run" not in [t for t, d in args]:
|
||
dst = next((args[k + 1] for k, (t, d) in enumerate(args[:-1]) if t == "-d"), None)
|
||
dst = dst or next(((t.split("=", 1)[1], d) for t, d in args if t.startswith("--directory=")), None)
|
||
file_(dst or (".", False), "patch")
|
||
elif head in ("npm", "yarn", "pnpm"):
|
||
if not any(t in ("-g", "--global") for t, d in args):
|
||
ops = _operands(args)
|
||
sub = ops[0][0] if ops else ("install" if head == "yarn" else "")
|
||
if sub in ("install", "i", "ci", "add", "init"):
|
||
out.append(Target("file", cur, head + " " + sub))
|
||
elif head == "openspec":
|
||
ops = _operands(args)
|
||
if ops and ops[0][0] in ("archive", "new", "init", "update"):
|
||
out.append(Target("file", cur, "openspec " + ops[0][0]))
|
||
elif head == "git":
|
||
_git(args, st)
|
||
return _SAME
|
||
|
||
|
||
def _git(args, st):
|
||
repo, k, unknown = st.cur, 0, False
|
||
while k < len(args):
|
||
t, d = args[k]
|
||
if t == "-C" and k + 1 < len(args):
|
||
v = args[k + 1]
|
||
if v[1]:
|
||
unknown = True
|
||
else:
|
||
repo = _join(repo, v[0])
|
||
k += 2
|
||
elif t in ("-c", "--namespace", "--exec-path", "--config-env") and k + 1 < len(args):
|
||
k += 2
|
||
elif t.startswith("--git-dir") or t.startswith("--work-tree"):
|
||
unknown = True
|
||
k += 1 if "=" in t else 2
|
||
elif t.startswith("-"):
|
||
k += 1
|
||
else:
|
||
break
|
||
if k >= len(args):
|
||
return
|
||
sub, rest = args[k][0], args[k + 1:]
|
||
path = None if unknown or repo is None else repo
|
||
words = [t for t, d in rest]
|
||
add = st.out.append
|
||
if sub in GIT_MUT:
|
||
add(Target("git", path, sub))
|
||
elif sub == "apply":
|
||
if not any(w in ("--check", "--stat", "--numstat", "--summary") for w in words):
|
||
add(Target("git", path, "apply"))
|
||
elif sub == "stash":
|
||
s2 = next((w for w in words if not w.startswith("-")), "push")
|
||
if s2 not in ("list", "show"):
|
||
add(Target("git", path, "stash"))
|
||
elif sub == "checkout":
|
||
flag = next((j for j, w in enumerate(words) if w in ("-b", "-B", "--orphan")), None)
|
||
if flag is not None and flag + 1 < len(rest):
|
||
add(Target("switch", path, _branch(rest[flag + 1])))
|
||
elif "--" in words and words.index("--") < len(words) - 1:
|
||
add(Target("git", path, "checkout --"))
|
||
elif sub == "switch":
|
||
name, found = None, False
|
||
for j, w in enumerate(words):
|
||
if w in ("-c", "-C", "--create", "--force-create", "--orphan") and j + 1 < len(rest):
|
||
name, found = _branch(rest[j + 1]), True
|
||
break
|
||
if w in ("-d", "--detach"):
|
||
name, found = "", True
|
||
break
|
||
if not found:
|
||
ops = _operands(rest)
|
||
if ops:
|
||
name, found = _branch(ops[0]), True
|
||
if found:
|
||
add(Target("switch", path, name))
|
||
elif sub == "push":
|
||
_push(rest, path, add)
|
||
|
||
|
||
def _push(rest, path, add):
|
||
delete, everything, pos, k = False, False, [], 0
|
||
while k < len(rest):
|
||
t = rest[k][0]
|
||
if t in ("--delete", "-d"):
|
||
delete = True
|
||
elif t in ("--all", "--mirror", "--branches"):
|
||
everything = True
|
||
elif t in ("--repo", "-o", "--push-option", "--receive-pack", "--exec"):
|
||
k += 1
|
||
elif not t.startswith("-"):
|
||
pos.append(rest[k])
|
||
k += 1
|
||
if everything:
|
||
add(Target("git", path, "push --all"))
|
||
return
|
||
refs = pos[1:]
|
||
if not refs:
|
||
if not delete:
|
||
add(Target("git", path, "push"))
|
||
return
|
||
for text, dyn in refs:
|
||
src, colon, dst = text.lstrip("+").partition(":")
|
||
if delete or not colon:
|
||
dst = src
|
||
if dst.startswith("refs/heads/"):
|
||
dst = dst[len("refs/heads/"):]
|
||
if dst == "HEAD" and not delete and not colon:
|
||
add(Target("git", path, "push"))
|
||
continue
|
||
name = _branch((dst, dyn))
|
||
if name is None:
|
||
add(Target("git", path, "push?"))
|
||
elif name in ("main", "master"):
|
||
add(Target("git", path, "push>" + name))
|
||
|
||
|
||
# ---------- файловая система и вердикт ----------
|
||
|
||
_cache = {}
|
||
|
||
|
||
def _git_out(d, *args):
|
||
env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
|
||
r = subprocess.run(["git", "-C", d] + list(args), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||
universal_newlines=True, timeout=5, env=env)
|
||
return r.stdout.strip() if r.returncode == 0 else None
|
||
|
||
|
||
def resolve(path):
|
||
"""(корень, ветка, реальный путь) git-репозитория, в котором лежит path, или None — вне репозитория.
|
||
Ветка '' — detached HEAD. Симлинки раскрываются до поиска (в том числе висячие: запись создаст файл у цели
|
||
ссылки), затем берётся ближайший существующий предок."""
|
||
real = os.path.realpath(path)
|
||
p = real
|
||
while not os.path.exists(p):
|
||
parent = os.path.dirname(p)
|
||
if parent == p:
|
||
return None
|
||
p = parent
|
||
d = p if os.path.isdir(p) else os.path.dirname(p)
|
||
if d not in _cache:
|
||
root = _git_out(d, "rev-parse", "--show-toplevel")
|
||
branch = _git_out(root, "branch", "--show-current") if root else None
|
||
_cache[d] = (root, branch or "") if root else None
|
||
hit = _cache[d]
|
||
if not hit:
|
||
return None
|
||
return hit[0], hit[1], real
|
||
|
||
|
||
def _label(branch):
|
||
return "detached HEAD" if branch == "" else branch
|
||
|
||
|
||
WT_HINT = "Работай в worktree на ветке (superpowers:using-git-worktrees)."
|
||
|
||
|
||
def decide_guard(tool, ti, cwd):
|
||
"""Сообщение блокировки guard-main или None."""
|
||
cwd = cwd or None
|
||
if tool == "Bash":
|
||
targets = parse(ti.get("command") or "", cwd)
|
||
elif tool in ("Edit", "Write", "MultiEdit"):
|
||
fp = ti.get("file_path") or ""
|
||
targets = [Target("file", _join(cwd, fp), tool)] if fp else []
|
||
else:
|
||
return None
|
||
over = {} # корень → ветка после git switch/checkout -b в этой же команде (None — не определена)
|
||
for t in targets:
|
||
if t.kind == "switch":
|
||
r = resolve(t.path) if t.path else None
|
||
if r:
|
||
over[r[0]] = t.op
|
||
continue
|
||
if t.path is None:
|
||
if t.kind == "git":
|
||
if t.op == "push?":
|
||
return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно."
|
||
return ("BLOCKED [guard-main]: git %s — репозиторий не определён (переменная в -C, --git-dir или "
|
||
"--work-tree). Укажи git -C <абсолютный путь>." % t.op)
|
||
continue
|
||
r = resolve(t.path)
|
||
if not r:
|
||
continue
|
||
root, branch, real = r
|
||
if root in over:
|
||
branch = over[root]
|
||
if t.kind == "git":
|
||
if t.op.startswith("push>") or t.op == "push --all":
|
||
return "BLOCKED [guard-main]: git %s — репозиторий %s. В main только через PR." % (
|
||
t.op.replace(">", " в "), root)
|
||
if t.op == "push?":
|
||
return "BLOCKED [guard-main]: git push — целевая ветка не определена. Укажи ветку явно."
|
||
if branch is None or branch in PROTECTED:
|
||
return "BLOCKED [guard-main]: git %s — репозиторий %s на %s. В main только через PR. %s" % (
|
||
t.op, root, "неизвестной ветке" if branch is None else _label(branch), WT_HINT)
|
||
elif branch in PROTECTED:
|
||
return "BLOCKED [guard-main]: запись в %s — репозиторий %s на %s. %s" % (
|
||
os.path.relpath(real, root), root, _label(branch), WT_HINT)
|
||
return None
|
||
|
||
|
||
def decide_plan(fp, cwd):
|
||
"""Сообщение блокировки require-plan или None."""
|
||
path = _join(cwd or None, fp) if fp else None
|
||
r = resolve(path) if path else None
|
||
if not r:
|
||
return None
|
||
root, branch, real = r
|
||
rel = os.path.relpath(real, root)
|
||
cp = os.path.join(root, ".claude", "code-paths")
|
||
if os.path.isfile(cp):
|
||
prefixes = [l.split("#", 1)[0].strip().rstrip("/") for l in open(cp, encoding="utf-8", errors="replace")]
|
||
protected = any(p and rel.startswith(p + "/") for p in prefixes)
|
||
else:
|
||
protected = "/src/" in "/" + rel
|
||
if not protected or os.path.exists(os.path.join(root, ".bounded")):
|
||
return None
|
||
plans = sorted(glob.glob(os.path.join(root, "docs", "superpowers", "plans", "*.md")))
|
||
if plans:
|
||
if branch == "" or len(plans) == 1:
|
||
return None
|
||
for p in plans:
|
||
if branch in open(p, encoding="utf-8", errors="replace").read():
|
||
return None
|
||
return ("BLOCKED [require-plan]: правка %s без плана для ветки %s. Запусти superpowers:brainstorming; "
|
||
"для bounded-задачи создай %s/.bounded." % (rel, _label(branch), root))
|
||
|
||
|
||
def main(argv):
|
||
mode = argv[1] if len(argv) > 1 else ""
|
||
if mode == "targets":
|
||
try:
|
||
ts = parse(argv[2] if len(argv) > 2 else "", argv[3] if len(argv) > 3 else os.getcwd())
|
||
except Exception as e:
|
||
print("error %s" % e)
|
||
return 1
|
||
for t in ts:
|
||
print(t.kind, "-" if t.path is None else t.path, "-" if t.op is None else t.op)
|
||
return 0
|
||
try:
|
||
d = json.loads(sys.stdin.read())
|
||
ti = d.get("tool_input") or {}
|
||
if mode == "guard-main":
|
||
msg = decide_guard(d.get("tool_name") or "", ti, d.get("cwd") or "")
|
||
elif mode == "require-plan":
|
||
msg = decide_plan(ti.get("file_path") or ti.get("path") or "", d.get("cwd") or "")
|
||
else:
|
||
msg = None
|
||
except Exception:
|
||
return 0
|
||
if msg:
|
||
sys.stderr.write(msg + "\n")
|
||
return 2
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv))
|