resync.py поднят в scripts/: пересборка копий из домов
copies.py находит расхождение, а чинить его руками — та же работа, из-за которой копии и расходятся: правка дома касается стольких файлов, сколько у него копий, и последний забывают. Проверка была, починки не было. Разметку разбирает не свой код: copies.py импортируется целиком, иначе второй разборщик той же разметки разошёлся бы с первым молча. В гейт не ставится намеренно. Автоматическая пересборка протащила бы правку дома во все копии мимо глаз автора, а копия, уезжающая в репозиторий проекта, тянет ещё и запись в журнал версий канона — этого машина не напишет. Проверено на обеих формах копии: обычной и лежащей внутри объемлющей ограды. Ограда и пустые строки по краям принадлежат месту, а не дому, и пересборка возвращает их такими, какими были — round-trip побайтно чистый на всех 24 копиях.
This commit is contained in:
@@ -262,7 +262,7 @@ claude plugin uninstall <плагин>@av-dev-skills --scope project
|
|||||||
<plugin>/skills/<skill>/scripts/ tasks.py, docs.py, openspec.py
|
<plugin>/skills/<skill>/scripts/ tasks.py, docs.py, openspec.py
|
||||||
<plugin>/agents/ charter'ы сабагентов
|
<plugin>/agents/ charter'ы сабагентов
|
||||||
shared/ дома правил, общих для нескольких плагинов
|
shared/ дома правил, общих для нескольких плагинов
|
||||||
scripts/ проверки репозитория: копии, адреса, диаграммы, фронтматтеры
|
scripts/ проверки репозитория и пересборка копий
|
||||||
pyproject.toml линтеры скриптов, только для этого репозитория
|
pyproject.toml линтеры скриптов, только для этого репозитория
|
||||||
lefthook.yml гейт коммита: проверки документов
|
lefthook.yml гейт коммита: проверки документов
|
||||||
```
|
```
|
||||||
@@ -355,6 +355,32 @@ uv run python scripts/copies.py # 0 сошлось, 1 расхождение
|
|||||||
на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит —
|
на тот файл, дом остался без копий, разметка сломана. Чего он **не** ловит —
|
||||||
копию, которую забыли пометить: помечать — по-прежнему решение человека.
|
копию, которую забыли пометить: помечать — по-прежнему решение человека.
|
||||||
|
|
||||||
|
### Пересборка — `scripts/resync.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 scripts/resync.py # переписать тела всех разошедшихся копий из домов
|
||||||
|
# 0 готово, 2 разметка сломана, 3 не тот каталог
|
||||||
|
```
|
||||||
|
|
||||||
|
Правка дома касается стольких файлов, сколько у него копий, и последний из них
|
||||||
|
забывают — это и есть причина, по которой копии расходятся. Пересборка делает то
|
||||||
|
же машиной и потому дословна по построению.
|
||||||
|
|
||||||
|
**В гейт коммита скрипт не ставится, и это решение.** Автоматическая пересборка
|
||||||
|
протащила бы правку дома во все копии мимо глаз автора, а правка дома, чья копия
|
||||||
|
уезжает в репозиторий проекта, обязана ещё и попасть в журнал версий канона —
|
||||||
|
этого машина не напишет. Гейт поэтому только **называет** расхождение; согласие с
|
||||||
|
ним остаётся действием человека.
|
||||||
|
|
||||||
|
Разметку разбирает не он сам: `copies.py` импортируется целиком. Второй
|
||||||
|
разборщик той же разметки разошёлся бы с первым молча — ровно тот класс дефекта,
|
||||||
|
против которого механика копий и заведена.
|
||||||
|
|
||||||
|
**Ограда блока кода принадлежит месту, а не дому.** Одно и то же тело живёт в
|
||||||
|
доме внутри ```` ``` ````, а в скелете канона — внутри чужой, объемлющей ограды,
|
||||||
|
и своей там иметь не должно. Пересборка берёт тело дома без крайних оград и
|
||||||
|
надевает обратно ту, что была у копии; пустые строки по краям — так же.
|
||||||
|
|
||||||
## Проверка адресов документов
|
## Проверка адресов документов
|
||||||
|
|
||||||
Адрес документа принадлежит одному плагину, а называют его все: `docs/*` стоит
|
Адрес документа принадлежит одному плагину, а называют его все: `docs/*` стоит
|
||||||
@@ -452,5 +478,8 @@ Glob разводит две половины: коммит, трогающий
|
|||||||
(`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в
|
(`stage_fixed: true`). Иначе исправленный файл остался бы в рабочем дереве, а в
|
||||||
историю уехал бы невычищенный — худший из исходов: гейт зелёный, коммит грязный.
|
историю уехал бы невычищенный — худший из исходов: гейт зелёный, коммит грязный.
|
||||||
|
|
||||||
|
**`resync.py` в гейте нет намеренно** — он чинит, а не проверяет, и его правка
|
||||||
|
обязана быть прочитана глазами (см. выше).
|
||||||
|
|
||||||
**Обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая,
|
**Обход разовый — `LEFTHOOK=0 git commit …`.** Он законен ровно для случая,
|
||||||
когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет.
|
когда найденное нечем чинить прямо сейчас; молча пропущенная проверка — нет.
|
||||||
|
|||||||
@@ -63,5 +63,10 @@ project-includes = [
|
|||||||
"scripts/copies.py",
|
"scripts/copies.py",
|
||||||
"scripts/diagrams.py",
|
"scripts/diagrams.py",
|
||||||
"scripts/frontmatter.py",
|
"scripts/frontmatter.py",
|
||||||
|
"scripts/resync.py",
|
||||||
]
|
]
|
||||||
python-version = "3.12"
|
python-version = "3.12"
|
||||||
|
# resync.py импортирует разборщик разметки из copies.py: второй разборщик той же
|
||||||
|
# разметки разошёлся бы с первым молча. Оба лежат в scripts/ и в чужой проект не
|
||||||
|
# уезжают, поэтому импорт между ними законен — но искать его надо здесь.
|
||||||
|
search-path = ["scripts"]
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Пересборка помеченных копий из их домов.
|
||||||
|
|
||||||
|
`copies.py` ловит расхождение, но чинить его руками — та же работа, из-за
|
||||||
|
которой копии и расходятся: правка дома касается семи файлов, и седьмой
|
||||||
|
забывают. Здесь она делается машиной и потому дословна по построению.
|
||||||
|
|
||||||
|
**В гейт коммита этот скрипт не ставится, и это решение, а не недосмотр.**
|
||||||
|
Автоматическая пересборка на коммите протащила бы правку дома в семь файлов
|
||||||
|
мимо глаз автора — а правка дома, чья копия уезжает в репозиторий проекта,
|
||||||
|
обязана ещё и попасть в журнал версий канона. Гейт поэтому только **называет**
|
||||||
|
расхождение, а согласие с ним остаётся действием человека.
|
||||||
|
|
||||||
|
Разметку разбирает `copies.py` — он импортируется целиком. Второй разборщик той
|
||||||
|
же разметки разошёлся бы с первым молча, и это ровно тот класс дефекта, против
|
||||||
|
которого вся механика копий и заведена.
|
||||||
|
|
||||||
|
**Ограда блока кода принадлежит месту, а не дому.** Тело `журнал-дефектов-форма`
|
||||||
|
живёт в доме внутри ```, а в скелете канона лежит внутри чужой, объемлющей
|
||||||
|
ограды — и своей там иметь не должно. Поэтому при пересборке берётся тело дома
|
||||||
|
без крайних оград, а обратно надевается **та ограда, что была у копии**.
|
||||||
|
|
||||||
|
resync.py [корень]
|
||||||
|
|
||||||
|
Коды выхода — общий словарь скриптов av-dev:
|
||||||
|
0 готово (в том числе «нечего пересобирать»)
|
||||||
|
2 ошибка употребления: незакрытый маркер, дубль id, копия без дома
|
||||||
|
3 окружение: не тот каталог
|
||||||
|
4 внутренний сбой
|
||||||
|
"""
|
||||||
|
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user