diff --git a/hooks/harness_target.py b/hooks/harness_target.py new file mode 100644 index 0000000..1850e02 --- /dev/null +++ b/hooks/harness_target.py @@ -0,0 +1,660 @@ +#!/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)) diff --git a/tests/test-hook-parse.sh b/tests/test-hook-parse.sh new file mode 100644 index 0000000..55236d1 --- /dev/null +++ b/tests/test-hook-parse.sh @@ -0,0 +1,93 @@ +# harness_target.py targets: разбор команды Bash на цели записи, без файловой системы (спека hook-guards). +P="$ROOT/hooks/harness_target.py" +assert_file "harness_target.py существует" "$P" +t(){ python3 "$P" targets "$1" /r 2>&1; } +NL=$'\n' +# чтение и дескрипторы +assert_eq "grep с текстом «npm i» — целей нет" "$(t 'grep -rn "npm i\|npm install" docs | head')" "" +assert_eq "2>/dev/null и 2>&1 — не цели" "$(t 'ls x 2>/dev/null; git fetch origin --prune 2>&1 | tail -3')" "" +assert_eq ">&2 и 1>&- — не цели" "$(t 'echo x >&2; echo y 1>&-')" "" +assert_eq "регулярка с > в кавычках" "$(t "python3 -c 're.search(r\"\\d>{1,2}\", s); mkdir'")" "" +assert_eq "комментарий" "$(t 'echo x # > f')" "" +# редиректы +assert_eq "редирект" "$(t 'echo x > notes.md')" "file /r/notes.md >" +assert_eq "дозапись по абсолютному пути" "$(t 'echo x >> /tmp/a.log')" "file /tmp/a.log >>" +assert_eq "stderr в файл" "$(t 'make 2>build.log')" "file /r/build.log 2>" +assert_eq "echo 2 > f — 2 это аргумент" "$(t 'echo 2 > f')" "file /r/f >" +assert_eq "&> в файл" "$(t 'cmd &> out.log')" "file /r/out.log &>" +assert_eq "heredoc: цель — файл, тело не разбирается" "$(t "cat > CLAUDE.md <<'EOF'${NL}a > b${NL}rm -rf x${NL}EOF${NL}echo ok")" "file /r/CLAUDE.md >" +# команды записи +assert_eq "tee -a" "$(t 'echo x | tee -a a.log b.log')" "file /r/a.log tee${NL}file /r/b.log tee" +assert_eq "cp — назначение" "$(t 'cp a b dst/')" "file /r/dst cp" +assert_eq "cp -t" "$(t 'cp -t out a b')" "file /r/out cp" +assert_eq "mv — источник и назначение" "$(t 'mv a b')" "file /r/a mv${NL}file /r/b mv" +assert_eq "rm -rf" "$(t 'rm -rf build')" "file /r/build rm" +assert_eq "chmod — без режима" "$(t 'chmod +x run.sh')" "file /r/run.sh chmod" +assert_eq "mkdir -p .wt/" "$(t 'mkdir -p .wt/6-x')" "file /r/.wt/6-x mkdir" +assert_eq "glob — каталог до метасимвола" "$(t 'rm docs/*.md')" "file /r/docs rm" +assert_eq "sed -i" "$(t "sed -i 's/a/b/' x.md")" "file /r/x.md sed -i" +assert_eq "sed -i '' (BSD)" "$(t "sed -i '' 's/a/b/' x.md")" "file /r/x.md sed -i" +assert_eq "sed -i -e, два файла" "$(t 'sed -i -e s/a/b/ x.md y.md')" "file /r/x.md sed -i${NL}file /r/y.md sed -i" +assert_eq "sed -n — чтение" "$(t 'sed -n 1,5p x.md')" "" +assert_eq "perl -pi -e" "$(t "perl -pi -e 's/a/b/' x")" "file /r/x perl -i" +assert_eq "dd of=" "$(t 'dd if=a of=/r/b.img')" "file /r/b.img dd" +assert_eq "tar -x -C" "$(t 'tar -xzf a.tgz -C out')" "file /r/out tar -x" +assert_eq "tar c — не запись" "$(t 'tar czf a.tgz src')" "" +assert_eq "unzip -d" "$(t 'unzip a.zip -d out')" "file /r/out unzip" +assert_eq "unzip -l — чтение" "$(t 'unzip -l a.zip')" "" +assert_eq "npm install — текущий каталог" "$(t 'npm install')" "file /r npm install" +assert_eq "npm -g install — не репозиторий" "$(t 'npm -g install x')" "" +assert_eq "pip install — не репозиторий" "$(t 'pip install requests')" "" +assert_eq "openspec archive" "$(t 'openspec archive x')" "file /r openspec archive" +assert_eq "openspec validate — чтение" "$(t 'openspec validate x --strict')" "" +assert_eq "docker … sh -c — не локальная запись" "$(t "docker compose exec -T db sh -c 'psql > /tmp/x'")" "" +assert_eq "префиксы sudo/env/присваивания" "$(t 'sudo rm /etc/x; env A=1 touch f; A=1 B=2 touch g')" "file /etc/x rm${NL}file /r/f touch${NL}file /r/g touch" +assert_eq "xargs rm — цели неизвестны" "$(t 'xargs rm < list')" "file - rm" +assert_eq "if/then" "$(t 'if [ -f x ]; then rm x; fi')" "file /r/x rm" +# каталог +assert_eq "cd && запись" "$(t 'cd /w && echo x > f')" "file /w/f >" +assert_eq "cd относительный ;" "$(t 'cd sub; touch f')" "file /r/sub/f touch" +assert_eq "( cd ) не протекает" "$(t '(cd /w; touch a); touch b')" "file /w/a touch${NL}file /r/b touch" +assert_eq "cd в конвейере не протекает" "$(t 'cd /w | cat; touch b')" "file /r/b touch" +assert_eq "pushd/popd" "$(t 'pushd /w && touch a && popd && touch b')" "file /w/a touch${NL}file /r/b touch" +assert_eq "cd - — каталог неизвестен" "$(t 'cd - && touch a')" "file - touch" +assert_eq "bash -c разбирается" "$(t "bash -c 'echo x > f'")" "file /r/f >" +assert_eq "eval разбирается" "$(t 'eval "rm x"')" "file /r/x rm" +# переменные и HOME +assert_eq "цель в переменной" "$(t 'echo x > "$OUT"')" "file - >" +assert_eq "\$HOME раскрывается" "$(t 'echo x > $HOME/f')" "file $HOME/f >" +assert_eq "~ раскрывается" "$(t 'echo x > ~/f')" "file $HOME/f >" +assert_eq "ln в переменную" "$(t 'ln -sfn ../../fixtures "$WT/fixtures"')" "file - ln" +# git +assert_eq "git add && commit" "$(t 'git add -A && git commit -m x')" "git /r add${NL}git /r commit" +assert_eq "git -C" "$(t 'git -C /w commit -m x')" "git /w commit" +assert_eq "git -C в переменной" "$(t 'for r in a b; do git -C "$r" commit -m x; done')" "git - commit" +assert_eq "git --git-dir" "$(t 'git --git-dir=/x/.git commit -m x')" "git - commit" +assert_eq "worktree, pull, fetch, log — не мутации" "$(t 'git worktree add ../w -b f; git pull --ff-only; git fetch; git log')" "" +assert_eq "stash list/show — чтение" "$(t 'git stash list; git stash show')" "" +assert_eq "stash — мутация" "$(t 'git stash')" "git /r stash" +assert_eq "checkout -b — смена ветки" "$(t 'git checkout -b f')" "switch /r f" +assert_eq "checkout -- пути" "$(t 'git checkout -- a.txt')" "git /r checkout --" +assert_eq "checkout ветки — не мутация" "$(t 'git checkout main')" "" +assert_eq "switch -c && commit" "$(t 'git switch -q -c feat/x && git commit -m y')" "switch /r feat/x${NL}git /r commit" +assert_eq "apply --check — чтение" "$(t 'git apply --check p.diff')" "" +assert_eq "apply — мутация" "$(t 'git apply p.diff')" "git /r apply" +assert_eq "commit с heredoc в подстановке" "$(t "git commit -m \"\$(cat <<'EOF'${NL}fix: it's (done)${NL}EOF${NL})\"")" "git /r commit" +# push +assert_eq "push без refspec" "$(t 'git push')" "git /r push" +assert_eq "push origin HEAD" "$(t 'git push -u origin HEAD')" "git /r push" +assert_eq "push ветки фичи" "$(t 'git push -u origin feat/x')" "" +assert_eq "push --delete ветки фичи" "$(t 'git push origin --delete feat/x')" "" +assert_eq "push HEAD:main" "$(t 'git push origin HEAD:main')" "git /r push>main" +assert_eq "push --delete main" "$(t 'git push origin --delete main')" "git /r push>main" +assert_eq "push --all" "$(t 'git push --all')" "git /r push --all" +assert_eq "push в переменную" "$(t 'git push origin "$BR"')" "git /r push?" +assert_eq "push feat/\$ID — префикс фичи" "$(t 'git push origin feat/$ID-x')" "" +# граничные случаи (Review Focus) +assert_eq "путь с пробелом в кавычках" "$(t 'touch "a b.md"')" "file /r/a b.md touch" +assert_eq "арифметика \$((…)) не ломает разбор" "$(t 'echo $((1+2)) > f')" "file /r/f >" +assert_eq "cd ~/x" "$(t 'cd ~/x && touch f')" "file $HOME/x/f touch" +assert_eq "git -C относительный после cd" "$(t 'cd /w && git -C sub commit -m x')" "git /w/sub commit" +assert_eq "перенос строки обратным слешем" "$(t "git add a \\${NL} b && git commit -m x")" "git /r add${NL}git /r commit" +# сбой разбора +assert_grep "незакрытая кавычка — error" '^error' <(t 'echo "x')