Files
dev-skills/scripts/resync.py
T
av b411d4edb8 оси: перечень получил дом, две бездомные оси переехали в shared
Слияние ничего из идей не тронуло, но сделало дешёвым дом для правила, натянутого
между скиллами. Заведён shared/axes.md — дом перечня, а не значений: девять осей,
их адреса и чего каждая не решает. Механика остаётся у владельца.

Целиком сюда переехали две оси, у которых владельца не было. Коды выхода
объявлялись общим словарём в одиннадцати местах, и каждое объявление называло
свой набор соседей; машина их не сверяла, потому что copies.py смотрит markdown,
а перечни лежали в docstring'ах. Теперь дом один, скрипты держат указатель, а три
SKILL.md — помеченную копию, потому что на кодах они ветвятся. Режим прогона
(с меткой, без метки) был размазан по четырём файлам и осью назван не был, хотя
в уставе review-basics задаёт саму возможность запуска.

Разведены два значения слова «стадия»: ступени 1-5 внутри прогона кода, стадии
дизайна и кода снаружи.

Карта нашла ошибку в себе: клетка «категория документа × метка» пустой не была —
review-basics приёмник проектных тем при любой метке. Пустой оказалась соседняя:
на прогоне без метки план фиксирован, и своих тем проекта в нём нет вовсе.
Обе оставшиеся пустоты названы вслух, а не заполнены наугад.
2026-08-13 12:16:38 +03:00

143 lines
7.3 KiB
Python

#!/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"<!-- копия: {copy.ident} из {home_rel} -->",
*lead, *body, *trail,
f"<!-- /копия: {copy.ident} -->",
]
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)