Files
dev-skills/scripts/copies.py
T
avandClaude Opus 5 885981ca39 проверка копий правил: маркеры дома и копии, побайтовая сверка
Разделение плагинов оставлено, цена названа: пять симметричных контрактов в двух
домах, два уже разошлись — форма журнала дефектов потеряла в копии поле
«Причина», список читателей docs/research/ потерял specs. Оба раза копия
выглядела актуальной и прошла мимо трёх ревью.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:23:03 +03:00

211 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)