Оба пункта заметок оказались одним классом: правило записано и никем не исполняется. Слаги. canon.md говорил «слаги файлов, capability и задач — английские, kebab-case» одной строкой в хвосте раскладки, а docs.py имён файлов не смотрел вовсе. Итог нашёлся в самом плагине: единственный пример ADR в скилле docs назывался ADR-2026-08-03-ochered-tablicej. Раскладка канона при этом приглашала к нарушению — в схеме стояли плейсхолдеры <тема>.md, то есть слово «тема» по-русски там, где надо писать <slug>. docs.py check теперь смотрит имена: кириллица и не-kebab-case жёстко, форма ADR-ГГГГ-ММ-ДД-slug.md жёстко, транслит эвристикой, то есть замечанием. Проверяются docs/conventions, docs/research, docs/adr и имена capability; каталог задач не трогается — его слаги ведёт tasks.py. Набор маркеров транслита подобран так, чтобы ложных срабатываний не было вовсе: выброшены ost (ловит post, cost), sch (schema), ya (yaml), nost (nostalgia), хвост ii (radii). Цена названа в комментарии — sostoyanie-partii проходит мимо. Правило, краснеющее на правде, приучает пролистывать весь блок, и это дороже пропуска. Агенты. В canon.md есть таблица «Что проверяет машина, а что человек», и её правая колонка — смысловой дубль, поведение в architecture.md, протухший факт, достаточность честной строки — три версии описывала работу, которую никто не делал: скилл canon предлагал агенту судить об этом самому, то есть проверять то, что он же и писал. Заведены двое, разрез по глубине — тот же довод, что развёл task-form и doc-wording. doc-consistency читает docs/ и openspec/, сверяет документы между собой (факт в двух домах, прямое противоречие, поведение в обзоре вместо спек, ADR без ссылки на design.md и без парного статуса, число без провенанса, заглушка вместо честной строки) и зовётся на шаге синка документации. doc-code-drift читает репозиторий, отвечает на «этот факт ещё верен» и зовётся раз в спринт на сессии. Перечень фактов, сверяемых с кодом, закрыт: имя ветки, команды, пути, зависимости поимённо, настройки с числом, единые точки проекта, capability, проверяемые инварианты. «Сверить архитектуру с кодом» — задача без дна, и агент, которому её поставили, выдаёт правдоподобную труху. Отсюда форма его доклада: начинается таблицей проверенного, а не находками, — по ней видно, чего он не смотрел. Карта домов уехала в устав doc-consistency помеченной копией: устав ссылался на файл плагина, а агент работает в репозитории проекта, где плагина может не быть. copies.py её сторожит. Попутно: докстрока copies.py показывала закрывающие маркеры как <!-- /дом -->, а код требует <!-- /дом: <id> -->. Нашлось первой же попыткой ими воспользоваться. DECISIONS тема 28 (ННОО–ХХЦЦ, следствия 105–108). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
214 lines
9.9 KiB
Python
214 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Сверка намеренных копий правил с их домом.
|
||
|
||
«Один факт — один дом» держится вниманием, и трижды подряд не удержалось: форма
|
||
записи журнала дефектов разошлась с домом на одно поле, список читателей
|
||
`docs/research/` — на один проход. Оба раза копия выглядела актуальной.
|
||
|
||
Копии всё же нужны: скелеты канона уезжают в репозиторий проекта и обязаны там
|
||
что-то говорить. Значит копия допустима, но обязана быть **дословной и
|
||
помеченной**.
|
||
|
||
Разметка — HTML-комментарии, невидимые в отрендеренном markdown и потому
|
||
безвредные внутри блоков, которые уезжают в проект. Наоборот, в проекте они
|
||
полезны: говорят, что текст имеет дом и правится там.
|
||
|
||
<!-- дом: <id> -->
|
||
…текст…
|
||
<!-- /дом: <id> -->
|
||
|
||
<!-- копия: <id> из <путь к файлу дома> -->
|
||
…тот же текст…
|
||
<!-- /копия: <id> -->
|
||
|
||
Закрывающий маркер несёт **тот же 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__", "av-dev-backlog"}
|
||
|
||
# Идентификатор — только буквы, цифры и дефис. Строгость намеренная: она же
|
||
# отличает **настоящий** маркер от примера в документации об этом механизме.
|
||
# Пример пишется с `<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)
|