From 885981ca39087eeeaa3bafa55adb704fc5c12833 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 3 Aug 2026 16:23:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B9=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB:=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5=D1=80?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BE=D0=BC=D0=B0=20=D0=B8=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8,=20=D0=BF=D0=BE=D0=B1=D0=B0=D0=B9=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разделение плагинов оставлено, цена названа: пять симметричных контрактов в двух домах, два уже разошлись — форма журнала дефектов потеряла в копии поле «Причина», список читателей docs/research/ потерял specs. Оба раза копия выглядела актуальной и прошла мимо трёх ревью. scripts/copies.py требует побайтового совпадения текста между маркерами. Комментарии, а не манифест копий: маркер уезжает в репозиторий проекта вместе со скелетом и там полезен — говорит, что у текста есть дом. Идентификатор строгий и повторяется в закрывающем маркере. Иначе документация о самом механизме объявляет дом и роняет проверку: это случилось на первом же прогоне, README объявил дом примером. Ограда блока кода в сверку не входит: в доме текст обрамлён своей оградой, в скелете лежит внутри чужой, объемлющей. Помечены два контракта. Второй пришлось сперва сделать дословным: копия говорила «обязателен статус», дом — «обязателен статус „заменено на“». Проверка не ловит копию, которую забыли пометить, — это сказано вслух, иначе зелёный прогон читался бы как «копий больше нет». И не заменяет запись в журнал версий канона: она видит, что копия отстала, но не что проект унёс старую версию. Co-Authored-By: Claude Opus 5 (1M context) --- DECISIONS.md | 50 +++++ README.md | 29 +++ TODO.md | 9 +- .../references/review-journal.md | 4 + av-dev-pm/skills/canon/references/canon.md | 2 + .../skills/canon/references/skeletons.md | 19 +- pyproject.toml | 1 + scripts/copies.py | 210 ++++++++++++++++++ 8 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 scripts/copies.py diff --git a/DECISIONS.md b/DECISIONS.md index a1fa6ca..397918e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -860,3 +860,53 @@ pyrefly: в окружении нет ничего, кроме линтеров, ни `plugin.json` не говорили, что `av-dev-pm` для конвейера **опционален**, а задача принимается текстом. Теперь говорят — это первое, что читает человек, выбирая, что подключать. + +## 12. Механическая проверка копий (2026-08-03) + +### Что было + +Разделение плагинов оставлено (тема 11), но цена его названа: пять симметричных +контрактов в двух домах, два уже разошлись — форма журнала дефектов потеряла в +копии поле «Причина», список читателей `docs/research/` потерял `specs`. Оба раза +копия выглядела актуальной, и оба раза расхождение прошло мимо трёх ревью подряд. + +### Решено + +**OO. Копия допустима, но обязана быть дословной и помеченной.** Разметка — +HTML-комментарии, невидимые в отрендеренном markdown: `` … +`` и `` … ``. +`scripts/copies.py` требует побайтового совпадения текста между маркерами. + +*Почему комментарии, а не манифест копий отдельным файлом:* маркер уезжает в +репозиторий проекта вместе со скелетом, и там он **полезен** — говорит читателю, +что у текста есть дом и правится он там. Манифест остался бы в маркетплейсе и +проекту ничего не сказал. + +**PP. Идентификатор строгий — буквы, цифры, дефис — и повторяется в закрывающем +маркере.** Иначе документация о самом механизме объявляет дом и роняет проверку: +это случилось на первом же прогоне, `README.md` объявил дом примером. Теперь +пример пишется ``, угловые скобки под шаблон не подходят. + +**QQ. Ограда блока кода в сверку не входит.** В доме текст обрамлён своей ```, +а в скелете тот же текст лежит внутри чужой, объемлющей ограды. Сверяется +содержимое, а не разметка вокруг него. + +**RR. Коды выхода — общий словарь** (0 сошлось, 1 расхождение, 2 разметка, +3 не тот каталог, 4 сбой). Третий скрипт репозитория, и третий по тем же кодам. + +### Что из этого следует + +50. **Помечены два контракта:** форма записи журнала дефектов (дом — конвейер + ревью, копия — скелет канона; это кросс-плагинная пара) и «когда заводить + ADR» (дом — канон, копия — его же скелет). Второй пришлось сперва **сделать** + дословным: копия говорила «обязателен статус», дом — «обязателен статус + „заменено на"», и это ровно тот класс, который и ищется. +51. **Чего проверка не ловит — копию, которую забыли пометить.** Помечать + остаётся решением человека, и это названо в `README.md` вслух: иначе зелёный + прогон читался бы как «копий больше нет». +52. **Дом без копий — расхождение, а не замечание.** Маркер, обещающий + дисциплину, за которой не за чем следить, — такая же ложная запись, как + разошедшаяся копия. +53. **Запись в журнал версий канона проверка не заменяет.** Она видит, что копия + отстала, но не видит, что проект уже унёс старую версию к себе. Это остаётся + на человеке и сказано в обоих домах. diff --git a/README.md b/README.md index 27d4f42..d309c30 100644 --- a/README.md +++ b/README.md @@ -138,3 +138,32 @@ uv run pyrefly check # типы `av-dev-backlog` из проверки исключён намеренно: плагин помечен устаревшим и живёт до перевода последнего проекта, после чего удаляется целиком. Правки в замороженный код — риск без выгоды. + +## Проверка копий правил + +«Один факт — один дом» держалось вниманием и трижды не удержалось. Копии всё же +нужны: скелеты канона уезжают в репозиторий проекта и обязаны там что-то +говорить. Значит копия допустима, но **дословная и помеченная**: + +``` +uv run python scripts/copies.py # 0 сошлось, 1 расхождение, 2 разметка, 3 не тот каталог +``` + +Разметка — HTML-комментарии, невидимые в отрендеренном markdown: + +``` + …текст… + …тот же текст… +``` + +Идентификатор — буквы, цифры и дефис, и он повторяется в закрывающем маркере. +Строгость нужна ровно затем, чтобы этот абзац сам не объявил дом: `` под +шаблон не подходит. + +Сверяется текст между маркерами; ограда блока кода и пустые строки по краям в +сверку не входят. Маркер, уехавший в проект вместе со скелетом, там полезен: он +говорит, что у текста есть дом и правится он там. + +Скрипт ловит четыре вещи: копия разошлась с домом (с диффом), копия указывает не +на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит — +копию, которую забыли пометить: помечать — по-прежнему решение человека. diff --git a/TODO.md b/TODO.md index 3b09785..d0ef6d6 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,7 @@ ## 0. Предусловие -- [ ] `git push` — 11 коммитов не отправлены на origin, поэтому маркетплейс их +- [ ] `git push` — 16 коммитов не отправлены на origin, поэтому маркетплейс их не видит и стоит на `092d07c` без единого нового плагина (37) - [ ] `claude plugin marketplace update av-dev-skills` — повторно, после push @@ -104,6 +104,13 @@ - [x] пайплайн не называет `items/` и `SPRINT.md` — их знает `av-dev-pm` - [x] манифесты объявили `av-dev-pm` опциональным для конвейера (49) +### 1.10 Механическая проверка копий (тема 12) + +- [x] `scripts/copies.py`: маркеры дома и копии, побайтовая сверка (OO) +- [x] строгий id, повторяемый в закрывающем маркере (PP) +- [x] помечены два контракта; «когда заводить ADR» сведён к дословному (50) +- [x] раздел «Проверка копий правил» в `README.md`, правило — в обоих домах + ## 2. healthlog — первая боевая проверка - [ ] `canon adopt`; `docs/backlog/` → `docs/tasks/` diff --git a/av-dev-pipeline/skills/review-pipeline/references/review-journal.md b/av-dev-pipeline/skills/review-pipeline/references/review-journal.md index 60b7a52..4f755b4 100644 --- a/av-dev-pipeline/skills/review-pipeline/references/review-journal.md +++ b/av-dev-pipeline/skills/review-pipeline/references/review-journal.md @@ -45,7 +45,10 @@ дословно — он уезжает в репозиторий и обязан там что-то говорить. Правка формы здесь **обязана** тянуть правку скелета и запись в журнал версий канона; иначе проекты продолжат писать по старой форме, а конвейер — ждать поля, которого нет. +Дословность сверяет `scripts/copies.py` маркетплейса по маркерам ниже — но +запись в журнал версий он не проверит, это остаётся на человеке. + ``` ## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман] @@ -58,6 +61,7 @@ - **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе проекта — либо «ничего, цена поимки выше цены дефекта» ``` + Пункт «чем воспроизведён» отличает запись от байки: без него на неё нельзя сослаться как на оракул. Регрессионный тест, написанный вместе с починкой, diff --git a/av-dev-pm/skills/canon/references/canon.md b/av-dev-pm/skills/canon/references/canon.md index 39d7938..d2e1573 100644 --- a/av-dev-pm/skills/canon/references/canon.md +++ b/av-dev-pm/skills/canon/references/canon.md @@ -152,10 +152,12 @@ kebab-case. Заводится, когда верно одно из трёх: + - **дорогой откат** — переделка стоит дороже переписывания одного файла; - **намеренный отказ** от очевидного подхода; - **пересмотр прежнего решения** — тогда у старой записи обязателен статус «заменено на». + Не заводится для рутины и для того, что видно из кода и `git log`. diff --git a/av-dev-pm/skills/canon/references/skeletons.md b/av-dev-pm/skills/canon/references/skeletons.md index f94b263..ca40f26 100644 --- a/av-dev-pm/skills/canon/references/skeletons.md +++ b/av-dev-pm/skills/canon/references/skeletons.md @@ -15,6 +15,14 @@ [changelog.md](changelog.md)** с указанием, какой файл проекта поднимает `upgrade`. Без этого копия в проекте останется на старой версии молча. +**Каждая такая копия помечена и сверяется машиной.** Дом обрамляется +`` … ``, копия — +`` … ``; +`scripts/copies.py` маркетплейса требует дословного +совпадения. Комментарии невидимы в отрендеренном markdown и уезжают в проект +вместе со скелетом — там они говорят читателю, что у текста есть дом. Правишь +текст внутри маркеров — правь дом, а не копию. + ## `docs/passport.md` ```markdown @@ -197,11 +205,14 @@ Верно одно из трёх: + - **дорогой откат** — переделка стоит дороже переписывания одного файла; - **намеренный отказ** от очевидного подхода; -- **пересмотр прежнего решения** — тогда у старой записи обязателен статус. +- **пересмотр прежнего решения** — тогда у старой записи обязателен статус + «заменено на». + -Не заводить для рутины и того, что видно из кода и `git log`. +Не заводить для рутины и для того, что видно из кода и `git log`. ## Соглашения @@ -285,7 +296,8 @@ Форма: -## ГГГГ-ММ-ДД — краткое последствие [проскочил|пойман] + +## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман] - **Где:** путь:строка либо «конвейер, а не код» - **Симптом:** как обнаружилось, кем и когда @@ -295,6 +307,7 @@ и что ему помешало - **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе проекта — либо «ничего, цена поимки выше цены дефекта» + ``` Новый проект: «Дефектов пока не было. Настройка конвейера появится с первым diff --git a/pyproject.toml b/pyproject.toml index 8b84ff9..18930e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,5 +61,6 @@ quote-style = "double" project-includes = [ "av-dev-pm/skills/tasks/scripts/tasks.py", "av-dev-pm/skills/canon/scripts/docs.py", + "scripts/copies.py", ] python-version = "3.12" diff --git a/scripts/copies.py b/scripts/copies.py new file mode 100644 index 0000000..261ce20 --- /dev/null +++ b/scripts/copies.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Сверка намеренных копий правил с их домом. + +«Один факт — один дом» держится вниманием, и трижды подряд не удержалось: форма +записи журнала дефектов разошлась с домом на одно поле, список читателей +`docs/research/` — на один проход. Оба раза копия выглядела актуальной. + +Копии всё же нужны: скелеты канона уезжают в репозиторий проекта и обязаны там +что-то говорить. Значит копия допустима, но обязана быть **дословной и +помеченной**. + +Разметка — HTML-комментарии, невидимые в отрендеренном markdown и потому +безвредные внутри блоков, которые уезжают в проект. Наоборот, в проекте они +полезны: говорят, что текст имеет дом и правится там. + + + …текст… + + + + …тот же текст… + + +Сверяется текст **между** маркерами: построчно, с отброшенными хвостовыми +пробелами и пустыми строками по краям. Всё остальное вокруг копии — предисловие, +повелительное наклонение, соседние разделы — принадлежит месту, а не дому, и +сверке не подлежит. + +Коды выхода — тот же словарь, что у tasks.py и docs.py: + 0 сошлось + 1 копия разошлась с домом (или дом остался без копий) + 2 ошибка употребления: незакрытый маркер, дубль id, копия без дома + 3 окружение: не тот каталог + 4 внутренний сбой +""" + +from __future__ import annotations + +import difflib +import re +import sys +from pathlib import Path + +OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4 + +SKIP_DIRS = {".git", ".venv", "node_modules", "__pycache__", "av-dev-backlog"} + +# Идентификатор — только буквы, цифры и дефис. Строгость намеренная: она же +# отличает **настоящий** маркер от примера в документации об этом механизме. +# Пример пишется с ``, и угловые скобки под шаблон не подходят — иначе текст, +# объясняющий разметку, объявлял бы дом и ронял проверку. +ID = r"[^\W_][\w-]*" +HOME_OPEN = re.compile(rf"") +HOME_CLOSE = re.compile(rf"") +COPY_OPEN = re.compile(rf"") +COPY_CLOSE = re.compile(rf"") + + +class Region: + """Помеченный кусок markdown: где начался, чем является, что внутри.""" + + def __init__(self, ident: str, path: Path, line: int, body: list[str], + declared_home: str | None = None) -> None: + self.ident = ident + self.path = path + self.line = line + self.body = body + self.declared_home = declared_home + + @property + def where(self) -> str: + return f"{self.path}:{self.line}" + + def normalized(self) -> list[str]: + out = [ln.rstrip() for ln in self.body] + while out and not out[0]: + out.pop(0) + while out and not out[-1]: + out.pop() + # Ограда блока кода в сверку не входит: в доме текст обычно обрамлён + # своим ```, а в скелете тот же текст лежит внутри чужой, объемлющей + # ограды. Сверяется содержимое, а не разметка вокруг него. + if out and out[0].startswith("```"): + out.pop(0) + if out and out[-1].startswith("```"): + out.pop() + return out + + +def scan(path: Path, errors: list[str]) -> tuple[list[Region], list[Region]]: + """Все маркеры одного файла. Незакрытый маркер — ошибка употребления.""" + homes: list[Region] = [] + copies: list[Region] = [] + open_at: tuple[str, int, str | None] | None = None + kind = "" + body: list[str] = [] + + for num, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if open_at is None: + if m := HOME_OPEN.search(line): + open_at, kind, body = (m.group(1), num, None), "дом", [] + elif m := COPY_OPEN.search(line): + open_at, kind, body = (m.group(1), num, m.group(2)), "копия", [] + elif HOME_CLOSE.search(line) or COPY_CLOSE.search(line): + errors.append(f"{path}:{num}: закрывающий маркер без открывающего") + continue + + ident, start, declared = open_at + closing = HOME_CLOSE.search(line) if kind == "дом" else COPY_CLOSE.search(line) + if closing: + if closing.group(1) != ident: + errors.append(f"{path}:{num}: закрывается «{closing.group(1)}»," + f" а открыт «{ident}» ({path}:{start})") + (homes if kind == "дом" else copies).append( + Region(ident, path, start, body, declared)) + open_at = None + continue + if HOME_OPEN.search(line) or COPY_OPEN.search(line): + errors.append(f"{path}:{num}: маркер внутри незакрытого «{kind}: {ident}»") + continue + body.append(line) + + if open_at is not None: + errors.append(f"{path}:{open_at[1]}: маркер «{kind}: {open_at[0]}» не закрыт") + return homes, copies + + +def walk(root: Path) -> list[Path]: + out = [] + for p in sorted(root.rglob("*.md")): + if SKIP_DIRS & set(p.relative_to(root).parts): + continue + out.append(p) + return out + + +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] = {} + copies: list[Region] = [] + for path in walk(root): + file_homes, file_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 + copies.extend(file_copies) + + if errors: + for e in errors: + print(f"УПОТРЕБЛЕНИЕ {e}", file=sys.stderr) + return USAGE + + drift: list[str] = [] + used: set[str] = set() + for c in copies: + home = homes.get(c.ident) + if home is None: + print(f"УПОТРЕБЛЕНИЕ {c.where}: копия «{c.ident}» без дома —" + f" дом либо не помечен, либо переименован", file=sys.stderr) + return USAGE + used.add(c.ident) + + actual = home.path.relative_to(root).as_posix() + if c.declared_home and not actual.endswith(c.declared_home.lstrip("./")): + drift.append(f"{c.where}: копия «{c.ident}» указывает на" + f" {c.declared_home}, а дом лежит в {actual}") + + if c.normalized() != home.normalized(): + diff = difflib.unified_diff(home.normalized(), c.normalized(), + fromfile=f"дом {home.where}", + tofile=f"копия {c.where}", lineterm="", n=1) + drift.append(f"«{c.ident}» разошлась с домом:\n " + + "\n ".join(diff)) + + for ident, home in homes.items(): + if ident not in used: + drift.append(f"{home.where}: дом «{ident}» помечен, а копий нет —" + f" запись обещает дисциплину, которой не за чем следить") + + print(f"копий помечено {len(copies)}, домов {len(homes)}") + if drift: + print() + for d in drift: + print(f"РАСХОЖДЕНИЕ {d}") + print(f"\nИтог: расхождений {len(drift)}. Правится **дом**, потом копия" + f" — и правка дома тянет запись в журнал версий канона," + f" если копия уезжает в проект.") + return DRIFT + + print("копии дословны") + 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)