From ee53ef8af8f633f4ef9967ecebe18494a781ab2f Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 9 Aug 2026 15:49:45 +0300 Subject: [PATCH] =?UTF-8?q?resync.py=20=D0=BF=D0=BE=D0=B4=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=20=D0=B2=20scripts/:=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=B4=D0=BE=D0=BC=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copies.py находит расхождение, а чинить его руками — та же работа, из-за которой копии и расходятся: правка дома касается стольких файлов, сколько у него копий, и последний забывают. Проверка была, починки не было. Разметку разбирает не свой код: copies.py импортируется целиком, иначе второй разборщик той же разметки разошёлся бы с первым молча. В гейт не ставится намеренно. Автоматическая пересборка протащила бы правку дома во все копии мимо глаз автора, а копия, уезжающая в репозиторий проекта, тянет ещё и запись в журнал версий канона — этого машина не напишет. Проверено на обеих формах копии: обычной и лежащей внутри объемлющей ограды. Ограда и пустые строки по краям принадлежат месту, а не дому, и пересборка возвращает их такими, какими были — round-trip побайтно чистый на всех 24 копиях. --- README.md | 31 +++++++++- pyproject.toml | 5 ++ scripts/resync.py | 145 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 scripts/resync.py diff --git a/README.md b/README.md index 86a83d6..7280d71 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ claude plugin uninstall <плагин>@av-dev-skills --scope project /skills//scripts/ tasks.py, docs.py, openspec.py /agents/ charter'ы сабагентов shared/ дома правил, общих для нескольких плагинов -scripts/ проверки репозитория: копии, адреса, диаграммы, фронтматтеры +scripts/ проверки репозитория и пересборка копий pyproject.toml линтеры скриптов, только для этого репозитория lefthook.yml гейт коммита: проверки документов ``` @@ -355,6 +355,32 @@ uv run python scripts/copies.py # 0 сошлось, 1 расхождение на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит — копию, которую забыли пометить: помечать — по-прежнему решение человека. +### Пересборка — `scripts/resync.py` + +``` +python3 scripts/resync.py # переписать тела всех разошедшихся копий из домов +# 0 готово, 2 разметка сломана, 3 не тот каталог +``` + +Правка дома касается стольких файлов, сколько у него копий, и последний из них +забывают — это и есть причина, по которой копии расходятся. Пересборка делает то +же машиной и потому дословна по построению. + +**В гейт коммита скрипт не ставится, и это решение.** Автоматическая пересборка +протащила бы правку дома во все копии мимо глаз автора, а правка дома, чья копия +уезжает в репозиторий проекта, обязана ещё и попасть в журнал версий канона — +этого машина не напишет. Гейт поэтому только **называет** расхождение; согласие с +ним остаётся действием человека. + +Разметку разбирает не он сам: `copies.py` импортируется целиком. Второй +разборщик той же разметки разошёлся бы с первым молча — ровно тот класс дефекта, +против которого механика копий и заведена. + +**Ограда блока кода принадлежит месту, а не дому.** Одно и то же тело живёт в +доме внутри ```` ``` ````, а в скелете канона — внутри чужой, объемлющей ограды, +и своей там иметь не должно. Пересборка берёт тело дома без крайних оград и +надевает обратно ту, что была у копии; пустые строки по краям — так же. + ## Проверка адресов документов Адрес документа принадлежит одному плагину, а называют его все: `docs/*` стоит @@ -452,5 +478,8 @@ Glob разводит две половины: коммит, трогающий (`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в историю уехал бы невычищенный — худший из исходов: гейт зелёный, коммит грязный. +**`resync.py` в гейте нет намеренно** — он чинит, а не проверяет, и его правка +обязана быть прочитана глазами (см. выше). + **Обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая, когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет. diff --git a/pyproject.toml b/pyproject.toml index a3f7232..09ff28d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,5 +63,10 @@ project-includes = [ "scripts/copies.py", "scripts/diagrams.py", "scripts/frontmatter.py", + "scripts/resync.py", ] python-version = "3.12" +# resync.py импортирует разборщик разметки из copies.py: второй разборщик той же +# разметки разошёлся бы с первым молча. Оба лежат в scripts/ и в чужой проект не +# уезжают, поэтому импорт между ними законен — но искать его надо здесь. +search-path = ["scripts"] diff --git a/scripts/resync.py b/scripts/resync.py new file mode 100644 index 0000000..eff67aa --- /dev/null +++ b/scripts/resync.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Пересборка помеченных копий из их домов. + +`copies.py` ловит расхождение, но чинить его руками — та же работа, из-за +которой копии и расходятся: правка дома касается семи файлов, и седьмой +забывают. Здесь она делается машиной и потому дословна по построению. + +**В гейт коммита этот скрипт не ставится, и это решение, а не недосмотр.** +Автоматическая пересборка на коммите протащила бы правку дома в семь файлов +мимо глаз автора — а правка дома, чья копия уезжает в репозиторий проекта, +обязана ещё и попасть в журнал версий канона. Гейт поэтому только **называет** +расхождение, а согласие с ним остаётся действием человека. + +Разметку разбирает `copies.py` — он импортируется целиком. Второй разборщик той +же разметки разошёлся бы с первым молча, и это ровно тот класс дефекта, против +которого вся механика копий и заведена. + +**Ограда блока кода принадлежит месту, а не дому.** Тело `журнал-дефектов-форма` +живёт в доме внутри ```, а в скелете канона лежит внутри чужой, объемлющей +ограды — и своей там иметь не должно. Поэтому при пересборке берётся тело дома +без крайних оград, а обратно надевается **та ограда, что была у копии**. + + resync.py [корень] + +Коды выхода — общий словарь скриптов av-dev: + 0 готово (в том числе «нечего пересобирать») + 2 ошибка употребления: незакрытый маркер, дубль id, копия без дома + 3 окружение: не тот каталог + 4 внутренний сбой +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import copies +from copies import ENV, INTERNAL, OK, USAGE, Region + + +def edges(body: list[str]) -> tuple[list[str], list[str]]: + """Ограда копии, если она есть: её надевают обратно на тело дома.""" + trimmed = [ln.rstrip() for ln in body] + while trimmed and not trimmed[0]: + trimmed.pop(0) + while trimmed and not trimmed[-1]: + trimmed.pop() + if len(trimmed) >= 2 and trimmed[0].startswith("```") and trimmed[-1].startswith("```"): + return [trimmed[0]], [trimmed[-1]] + return [], [] + + +def rebuild(path: Path, targets: list[tuple[Region, Region, str]]) -> None: + """Переписать тела копий в одном файле, идя снизу вверх по номерам строк.""" + lines = path.read_text(encoding="utf-8").splitlines() + for copy, home, home_rel in sorted(targets, key=lambda t: -t[0].line): + head, tail = edges(copy.body) + # Пустые строки по краям сверке не подлежат и потому принадлежат месту: + # у копии внутри объемлющей ограды их нет, у копии в прозе — есть. + # Ставим их так, как стояло, иначе пересборка правит и то, чего не + # чинила, и дифф перестаёт читаться. + lead = [""] if copy.body and not copy.body[0].strip() else [] + trail = [""] if copy.body and not copy.body[-1].strip() else [] + body = [*head, *home.normalized(), *tail] + start = copy.line - 1 # строка открывающего маркера + end = start + len(copy.body) + 1 # строка закрывающего + lines[start:end + 1] = [ + f"", + *lead, *body, *trail, + f"", + ] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve() + if not (root / ".claude-plugin").is_dir(): + print(f"ОТКАЗ: {root} не похож на корень маркетплейса: нет .claude-plugin/", + file=sys.stderr) + return ENV + + errors: list[str] = [] + homes: dict[str, Region] = {} + by_file: dict[Path, list[Region]] = {} + for path in copies.walk(root): + file_homes, file_copies = copies.scan(path, errors) + for h in file_homes: + if h.ident in homes: + errors.append(f"{h.where}: дом «{h.ident}» уже объявлен" + f" в {homes[h.ident].where} — id обязан быть один") + continue + homes[h.ident] = h + if file_copies: + by_file[path] = file_copies + + for file_copies in by_file.values(): + for c in file_copies: + if c.ident not in homes: + errors.append(f"{c.where}: копия «{c.ident}» без дома —" + f" дом либо не помечен, либо переименован") + + if errors: + for e in errors: + print(f"УПОТРЕБЛЕНИЕ {e}", file=sys.stderr) + print("\nПересборка не начата: разметка сломана, и починка её не лечит.", + file=sys.stderr) + return USAGE + + total, touched = 0, 0 + for path, file_copies in sorted(by_file.items()): + targets = [] + for c in file_copies: + home = homes[c.ident] + home_rel = home.path.relative_to(root).as_posix() + total += 1 + same_text = c.normalized() == home.normalized() + same_addr = bool(c.declared_home) and home_rel.endswith( + c.declared_home.lstrip("./")) + if same_text and same_addr: + continue + targets.append((c, home, home_rel)) + if not targets: + continue + rebuild(path, targets) + touched += len(targets) + rel = path.relative_to(root).as_posix() + print(f"{rel}: пересобрано {len(targets)} —" + f" {', '.join(t[0].ident for t in targets)}") + + print(f"копий {total}, домов {len(homes)}; пересобрано {touched}") + if touched: + print("\nПрочитай дифф перед коммитом. Копия, уезжающая в репозиторий " + "проекта, тянет ещё и запись в журнал версий канона — её машина " + "не напишет.") + return OK + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(INTERNAL) + except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю + print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr) + sys.exit(INTERNAL)