Files
avandClaude Opus 5 c3e0a6d01f удалён av-dev-backlog: заморозка стоила дороже, чем удаление
Плагин был помечен устаревшим решением Q и жил до перевода jellybit.
Удалён раньше этого срока: условие пережило свою причину.

Заморозка выглядела бесплатной, а платила собой в каждой проверке
репозитория — exclude в pyproject.toml, SKIP_DIRS в copies.py, два абзаца
README, оговорка в описании маркетплейса, чтобы не ловить триггер
«добавь задачу в беклог». Пять исключений ради 706 строк, которые никто
не читает, и каждое надо объяснять всякий раз, когда спрашивают, почему
проверка обходит каталог.

Причина условия отпала раньше названного срока. docs/backlog/ читает не
backlog.py, а av-dev-pm:tasks — adopt.md и адаптер в tasks.py держат ту
же раскладку как вход миграции. Плагин перестал быть единственным, кто
её знает, ещё когда писался adopt, и «живёт до перевода последнего
проекта» с тех пор охраняло пустоту. Перевод jellybit на канон это не
задевает.

Порядок вышел обратный ожидаемому: плагин удалён из маркетплейса, а с
jellybit снят после. Ожидалась ручная чистка enabledPlugins и
installed_plugins.json, но claude plugin uninstall отработал штатно — он
идёт по реестру, а не по манифесту маркетплейса. Раздел «Снятие» в README
переписан с частного случая на общую процедуру, предупреждение заменено
проверенным фактом.

Записи Q и HH получили парные статусы, тема 30 в DECISIONS несёт причины
и три следствия, пункт 5 TODO отмечен, открытый вопрос из REMAINING убран.

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

214 lines
9.9 KiB
Python
Raw Permalink 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> -->
<!-- копия: <id> из <путь к файлу дома> -->
…тот же текст…
<!-- /копия: <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__"}
# Идентификатор — только буквы, цифры и дефис. Строгость намеренная: она же
# отличает **настоящий** маркер от примера в документации об этом механизме.
# Пример пишется с `<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)