проверка копий правил: маркеры дома и копии, побайтовая сверка
Разделение плагинов оставлено, цена названа: пять симметричных контрактов в двух домах, два уже разошлись — форма журнала дефектов потеряла в копии поле «Причина», список читателей docs/research/ потерял specs. Оба раза копия выглядела актуальной и прошла мимо трёх ревью. scripts/copies.py требует побайтового совпадения текста между маркерами. Комментарии, а не манифест копий: маркер уезжает в репозиторий проекта вместе со скелетом и там полезен — говорит, что у текста есть дом. Идентификатор строгий и повторяется в закрывающем маркере. Иначе документация о самом механизме объявляет дом и роняет проверку: это случилось на первом же прогоне, README объявил дом примером. Ограда блока кода в сверку не входит: в доме текст обрамлён своей оградой, в скелете лежит внутри чужой, объемлющей. Помечены два контракта. Второй пришлось сперва сделать дословным: копия говорила «обязателен статус», дом — «обязателен статус „заменено на“». Проверка не ловит копию, которую забыли пометить, — это сказано вслух, иначе зелёный прогон читался бы как «копий больше нет». И не заменяет запись в журнал версий канона: она видит, что копия отстала, но не что проект унёс старую версию. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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. **Запись в журнал версий канона проверка не заменяет.** Она видит, что копия
|
||||
отстала, но не видит, что проект уже унёс старую версию к себе. Это остаётся
|
||||
на человеке и сказано в обоих домах.
|
||||
|
||||
@@ -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,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`.
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
и что ему помешало
|
||||
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
|
||||
проекта — либо «ничего, цена поимки выше цены дефекта»
|
||||
<!-- /копия: журнал-дефектов-форма -->
|
||||
```
|
||||
|
||||
Новый проект: «Дефектов пока не было. Настройка конвейера появится с первым
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user