#!/usr/bin/env python3 """Сверка намеренных копий правил с их домом. «Один факт — один дом» держится вниманием, и трижды подряд не удержалось: форма записи журнала дефектов разошлась с домом на одно поле, список читателей `docs/research/` — на один проход. Оба раза копия выглядела актуальной. Копии всё же нужны: скелеты канона уезжают в репозиторий проекта и обязаны там что-то говорить. Значит копия допустима, но обязана быть **дословной и помеченной**. Разметка — HTML-комментарии, невидимые в отрендеренном markdown и потому безвредные внутри блоков, которые уезжают в проект. Наоборот, в проекте они полезны: говорят, что текст имеет дом и правится там. …текст… …тот же текст… Закрывающий маркер несёт **тот же 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 = 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)