Files
dev-skills/scripts/copies.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

210 lines
9.7 KiB
Python
Raw 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**, что открывающий: без него не отличить
конец своего блока от конца соседнего, а вложенных блоков разметка не знает.
Сверяется текст **между** маркерами: построчно, с отброшенными хвостовыми
пробелами и пустыми строками по краям. Всё остальное вокруг копии — предисловие,
повелительное наклонение, соседние разделы — принадлежит месту, а не дому, и
сверке не подлежит.
Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф
против окружения» — av-dev/shared/axes.md. Значения — в константах ниже.
"""
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)