проверка копий правил: маркеры дома и копии, побайтовая сверка

Разделение плагинов оставлено, цена названа: пять симметричных контрактов в двух
домах, два уже разошлись — форма журнала дефектов потеряла в копии поле
«Причина», список читателей docs/research/ потерял specs. Оба раза копия
выглядела актуальной и прошла мимо трёх ревью.

scripts/copies.py требует побайтового совпадения текста между маркерами.
Комментарии, а не манифест копий: маркер уезжает в репозиторий проекта вместе со
скелетом и там полезен — говорит, что у текста есть дом.

Идентификатор строгий и повторяется в закрывающем маркере. Иначе документация о
самом механизме объявляет дом и роняет проверку: это случилось на первом же
прогоне, README объявил дом примером.

Ограда блока кода в сверку не входит: в доме текст обрамлён своей оградой, в
скелете лежит внутри чужой, объемлющей.

Помечены два контракта. Второй пришлось сперва сделать дословным: копия говорила
«обязателен статус», дом — «обязателен статус „заменено на“».

Проверка не ловит копию, которую забыли пометить, — это сказано вслух, иначе
зелёный прогон читался бы как «копий больше нет». И не заменяет запись в журнал
версий канона: она видит, что копия отстала, но не что проект унёс старую версию.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-03 16:23:03 +03:00
co-authored by Claude Opus 5
parent 5dcf40d8af
commit 885981ca39
8 changed files with 320 additions and 4 deletions
+50
View File
@@ -860,3 +860,53 @@ pyrefly: в окружении нет ничего, кроме линтеров,
ни `plugin.json` не говорили, что `av-dev-pm` для конвейера **опционален**, а
задача принимается текстом. Теперь говорят — это первое, что читает человек,
выбирая, что подключать.
## 12. Механическая проверка копий (2026-08-03)
### Что было
Разделение плагинов оставлено (тема 11), но цена его названа: пять симметричных
контрактов в двух домах, два уже разошлись — форма журнала дефектов потеряла в
копии поле «Причина», список читателей `docs/research/` потерял `specs`. Оба раза
копия выглядела актуальной, и оба раза расхождение прошло мимо трёх ревью подряд.
### Решено
**OO. Копия допустима, но обязана быть дословной и помеченной.** Разметка —
HTML-комментарии, невидимые в отрендеренном markdown: `<!-- дом: <id> -->`
`<!-- /дом: <id> -->` и `<!-- копия: <id> из <путь> -->``<!-- /копия: <id> -->`.
`scripts/copies.py` требует побайтового совпадения текста между маркерами.
*Почему комментарии, а не манифест копий отдельным файлом:* маркер уезжает в
репозиторий проекта вместе со скелетом, и там он **полезен** — говорит читателю,
что у текста есть дом и правится он там. Манифест остался бы в маркетплейсе и
проекту ничего не сказал.
**PP. Идентификатор строгий — буквы, цифры, дефис — и повторяется в закрывающем
маркере.** Иначе документация о самом механизме объявляет дом и роняет проверку:
это случилось на первом же прогоне, `README.md` объявил дом примером. Теперь
пример пишется `<id>`, угловые скобки под шаблон не подходят.
**QQ. Ограда блока кода в сверку не входит.** В доме текст обрамлён своей ```,
а в скелете тот же текст лежит внутри чужой, объемлющей ограды. Сверяется
содержимое, а не разметка вокруг него.
**RR. Коды выхода — общий словарь** (0 сошлось, 1 расхождение, 2 разметка,
3 не тот каталог, 4 сбой). Третий скрипт репозитория, и третий по тем же кодам.
### Что из этого следует
50. **Помечены два контракта:** форма записи журнала дефектов (дом — конвейер
ревью, копия — скелет канона; это кросс-плагинная пара) и «когда заводить
ADR» (дом — канон, копия — его же скелет). Второй пришлось сперва **сделать**
дословным: копия говорила «обязателен статус», дом — «обязателен статус
„заменено на"», и это ровно тот класс, который и ищется.
51. **Чего проверка не ловит — копию, которую забыли пометить.** Помечать
остаётся решением человека, и это названо в `README.md` вслух: иначе зелёный
прогон читался бы как «копий больше нет».
52. **Дом без копий — расхождение, а не замечание.** Маркер, обещающий
дисциплину, за которой не за чем следить, — такая же ложная запись, как
разошедшаяся копия.
53. **Запись в журнал версий канона проверка не заменяет.** Она видит, что копия
отстала, но не видит, что проект уже унёс старую версию к себе. Это остаётся
на человеке и сказано в обоих домах.
+29
View File
@@ -138,3 +138,32 @@ uv run pyrefly check # типы
`av-dev-backlog` из проверки исключён намеренно: плагин помечен устаревшим и
живёт до перевода последнего проекта, после чего удаляется целиком. Правки в
замороженный код — риск без выгоды.
## Проверка копий правил
«Один факт — один дом» держалось вниманием и трижды не удержалось. Копии всё же
нужны: скелеты канона уезжают в репозиторий проекта и обязаны там что-то
говорить. Значит копия допустима, но **дословная и помеченная**:
```
uv run python scripts/copies.py # 0 сошлось, 1 расхождение, 2 разметка, 3 не тот каталог
```
Разметка — HTML-комментарии, невидимые в отрендеренном markdown:
```
<!-- дом: <id> --> …текст… <!-- /дом: <id> -->
<!-- копия: <id> из <путь к дому> --> …тот же текст… <!-- /копия: <id> -->
```
Идентификатор — буквы, цифры и дефис, и он повторяется в закрывающем маркере.
Строгость нужна ровно затем, чтобы этот абзац сам не объявил дом: `<id>` под
шаблон не подходит.
Сверяется текст между маркерами; ограда блока кода и пустые строки по краям в
сверку не входят. Маркер, уехавший в проект вместе со скелетом, там полезен: он
говорит, что у текста есть дом и правится он там.
Скрипт ловит четыре вещи: копия разошлась с домом (с диффом), копия указывает не
на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит —
копию, которую забыли пометить: помечать — по-прежнему решение человека.
+8 -1
View File
@@ -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/`
@@ -45,7 +45,10 @@
дословно — он уезжает в репозиторий и обязан там что-то говорить. Правка формы
здесь **обязана** тянуть правку скелета и запись в журнал версий канона; иначе
проекты продолжат писать по старой форме, а конвейер — ждать поля, которого нет.
Дословность сверяет `scripts/copies.py` маркетплейса по маркерам ниже — но
запись в журнал версий он не проверит, это остаётся на человеке.
<!-- дом: журнал-дефектов-форма -->
```
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
@@ -58,6 +61,7 @@
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
проекта — либо «ничего, цена поимки выше цены дефекта»
```
<!-- /дом: журнал-дефектов-форма -->
Пункт «чем воспроизведён» отличает запись от байки: без него на неё нельзя
сослаться как на оракул. Регрессионный тест, написанный вместе с починкой,
@@ -152,10 +152,12 @@ kebab-case.
Заводится, когда верно одно из трёх:
<!-- дом: adr-когда-заводить -->
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
<!-- /дом: adr-когда-заводить -->
Не заводится для рутины и для того, что видно из кода и `git log`.
+16 -3
View File
@@ -15,6 +15,14 @@
[changelog.md](changelog.md)** с указанием, какой файл проекта поднимает
`upgrade`. Без этого копия в проекте останется на старой версии молча.
**Каждая такая копия помечена и сверяется машиной.** Дом обрамляется
`<!-- дом: <id> -->``<!-- /дом: <id> -->`, копия —
`<!-- копия: <id> из <путь> -->``<!-- /копия: <id> -->`;
`scripts/copies.py` маркетплейса требует дословного
совпадения. Комментарии невидимы в отрендеренном markdown и уезжают в проект
вместе со скелетом — там они говорят читателю, что у текста есть дом. Правишь
текст внутри маркеров — правь дом, а не копию.
## `docs/passport.md`
```markdown
@@ -197,11 +205,14 @@
Верно одно из трёх:
<!-- копия: adr-когда-заводить из av-dev-pm/skills/canon/references/canon.md -->
- **дорогой откат** — переделка стоит дороже переписывания одного файла;
- **намеренный отказ** от очевидного подхода;
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус.
- **пересмотр прежнего решения** — тогда у старой записи обязателен статус
«заменено на».
<!-- /копия: adr-когда-заводить -->
Не заводить для рутины и того, что видно из кода и `git log`.
Не заводить для рутины и для того, что видно из кода и `git log`.
## Соглашения
@@ -285,7 +296,8 @@
Форма:
## ГГГГ-ММ-ДД — краткое последствие [проскочил|пойман]
<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
- **Где:** путь:строка либо «конвейер, а не код»
- **Симптом:** как обнаружилось, кем и когда
@@ -295,6 +307,7 @@
и что ему помешало
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
проекта — либо «ничего, цена поимки выше цены дефекта»
<!-- /копия: журнал-дефектов-форма -->
```
Новый проект: «Дефектов пока не было. Настройка конвейера появится с первым
+1
View File
@@ -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"
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Сверка намеренных копий правил с их домом.
«Один факт — один дом» держится вниманием, и трижды подряд не удержалось: форма
записи журнала дефектов разошлась с домом на одно поле, список читателей
`docs/research/` — на один проход. Оба раза копия выглядела актуальной.
Копии всё же нужны: скелеты канона уезжают в репозиторий проекта и обязаны там
что-то говорить. Значит копия допустима, но обязана быть **дословной и
помеченной**.
Разметка — HTML-комментарии, невидимые в отрендеренном markdown и потому
безвредные внутри блоков, которые уезжают в проект. Наоборот, в проекте они
полезны: говорят, что текст имеет дом и правится там.
<!-- дом: <id> -->
…текст…
<!-- /дом -->
<!-- копия: <id> из <путь к файлу дома> -->
…тот же текст…
<!-- /копия -->
Сверяется текст **между** маркерами: построчно, с отброшенными хвостовыми
пробелами и пустыми строками по краям. Всё остальное вокруг копии — предисловие,
повелительное наклонение, соседние разделы — принадлежит месту, а не дому, и
сверке не подлежит.
Коды выхода — тот же словарь, что у 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>`, и угловые скобки под шаблон не подходят — иначе текст,
# объясняющий разметку, объявлял бы дом и ронял проверку.
ID = r"[^\W_][\w-]*"
HOME_OPEN = re.compile(rf"<!--\s*дом:\s*({ID})\s*-->")
HOME_CLOSE = re.compile(rf"<!--\s*/дом:\s*({ID})\s*-->")
COPY_OPEN = re.compile(rf"<!--\s*копия:\s*({ID})\s+из\s+(\S+?)\s*-->")
COPY_CLOSE = re.compile(rf"<!--\s*/копия:\s*({ID})\s*-->")
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)