#!/usr/bin/env python3 """Пересборка помеченных копий из их домов. `copies.py` ловит расхождение, но чинить его руками — та же работа, из-за которой копии и расходятся: правка дома касается семи файлов, и седьмой забывают. Здесь она делается машиной и потому дословна по построению. **В гейт коммита этот скрипт не ставится, и это решение, а не недосмотр.** Автоматическая пересборка на коммите протащила бы правку дома в семь файлов мимо глаз автора — а правка дома, чья копия уезжает в репозиторий проекта, обязана ещё и попасть в журнал версий канона. Гейт поэтому только **называет** расхождение, а согласие с ним остаётся действием человека. Разметку разбирает `copies.py` — он импортируется целиком. Второй разборщик той же разметки разошёлся бы с первым молча, и это ровно тот класс дефекта, против которого вся механика копий и заведена. **Ограда блока кода принадлежит месту, а не дому.** Тело `журнал-дефектов-форма` живёт в доме внутри ```, а в скелете канона лежит внутри чужой, объемлющей ограды — и своей там иметь не должно. Поэтому при пересборке берётся тело дома без крайних оград, а обратно надевается **та ограда, что была у копии**. resync.py [корень] Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф против окружения» — av-dev/shared/axes.md. Значения — в константах ниже. """ from __future__ import annotations import sys from pathlib import Path import copies from copies import ENV, INTERNAL, OK, USAGE, Region def edges(body: list[str]) -> tuple[list[str], list[str]]: """Ограда копии, если она есть: её надевают обратно на тело дома.""" trimmed = [ln.rstrip() for ln in body] while trimmed and not trimmed[0]: trimmed.pop(0) while trimmed and not trimmed[-1]: trimmed.pop() if len(trimmed) >= 2 and trimmed[0].startswith("```") and trimmed[-1].startswith("```"): return [trimmed[0]], [trimmed[-1]] return [], [] def rebuild(path: Path, targets: list[tuple[Region, Region, str]]) -> None: """Переписать тела копий в одном файле, идя снизу вверх по номерам строк.""" lines = path.read_text(encoding="utf-8").splitlines() for copy, home, home_rel in sorted(targets, key=lambda t: -t[0].line): head, tail = edges(copy.body) # Пустые строки по краям сверке не подлежат и потому принадлежат месту: # у копии внутри объемлющей ограды их нет, у копии в прозе — есть. # Ставим их так, как стояло, иначе пересборка правит и то, чего не # чинила, и дифф перестаёт читаться. lead = [""] if copy.body and not copy.body[0].strip() else [] trail = [""] if copy.body and not copy.body[-1].strip() else [] body = [*head, *home.normalized(), *tail] start = copy.line - 1 # строка открывающего маркера end = start + len(copy.body) + 1 # строка закрывающего lines[start:end + 1] = [ f"", *lead, *body, *trail, f"", ] path.write_text("\n".join(lines) + "\n", encoding="utf-8") 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] = {} by_file: dict[Path, list[Region]] = {} for path in copies.walk(root): file_homes, file_copies = 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 if file_copies: by_file[path] = file_copies for file_copies in by_file.values(): for c in file_copies: if c.ident not in homes: errors.append(f"{c.where}: копия «{c.ident}» без дома —" f" дом либо не помечен, либо переименован") if errors: for e in errors: print(f"УПОТРЕБЛЕНИЕ {e}", file=sys.stderr) print("\nПересборка не начата: разметка сломана, и починка её не лечит.", file=sys.stderr) return USAGE total, touched = 0, 0 for path, file_copies in sorted(by_file.items()): targets = [] for c in file_copies: home = homes[c.ident] home_rel = home.path.relative_to(root).as_posix() total += 1 same_text = c.normalized() == home.normalized() same_addr = bool(c.declared_home) and home_rel.endswith( c.declared_home.lstrip("./")) if same_text and same_addr: continue targets.append((c, home, home_rel)) if not targets: continue rebuild(path, targets) touched += len(targets) rel = path.relative_to(root).as_posix() print(f"{rel}: пересобрано {len(targets)} —" f" {', '.join(t[0].ident for t in targets)}") print(f"копий {total}, домов {len(homes)}; пересобрано {touched}") if touched: print("\nПрочитай дифф перед коммитом. Копия, уезжающая в репозиторий " "проекта, тянет ещё и запись в журнал версий канона — её машина " "не напишет.") 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)