слияние: три плагина стали одним av-dev, скиллы получили префиксы

Каталоги, агенты и общие дома переехали в av-dev/; скиллы названы по прежнему
плагину — doc-*, task-*, code-*, с двумя смысловыми именами вместо тавтологии:
doc-sync вместо docs, task-track вместо tasks. Манифесты сведены к двум
плагинам. Пространства имён вызовов и пути внутри дерева переписаны машинно;
проза, которая называет прежние плагины отдельными, идёт следующим шагом.
This commit is contained in:
av
2026-08-13 10:10:51 +03:00
parent 142659bfd1
commit de12a4d8a3
68 changed files with 222 additions and 248 deletions
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Форма `openspec/config.yaml`: проверка проекта и сверка слепка с инструментом.
Каталог `openspec/` — предпосылка **конвейера**, а не канона документов: без него
не работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Поэтому и
проверка формы живёт здесь, рядом со скиллом, который каталог заводит. Раньше она
жила в `docs.py` плагина канона, и у файла было два владельца: один заводит,
другой проверяет.
Проверяется то, что **молчит при поломке**. Файл из коробки хуже отсутствующего:
`openspec init` кладёт `config.yaml`, где `context` и `rules` — закомментированный
пример на английском. Он есть, он валиден, имя правильное — и читается как
настроенный, работая как пустой. Узнаётся это по уже написанному предложению.
Разбираем текстом, а не YAML-парсером: у скриптов ноль внешних зависимостей, а
PyYAML в стандартной библиотеке нет. Всё проверяемое различимо построчно,
комментарии отброшены, ключи верхнего уровня стоят в первой колонке.
Коды выхода — общий словарь скриптов av-dev:
0 сошлось
1 дрейф: форма разошлась с ожидаемой
2 ошибка употребления: аргументы
3 окружение: не тот каталог, инструмент не отвечает
4 внутренний сбой
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
# Команда заведения. Названа поимённо потому, что её печатает отказ, а отказ без
# команды заставляет искать её в другом месте.
OPENSPEC_INIT = "openspec init --tools claude"
# Адреса, которые обязан назвать блок context. Не пересказ документов, а именно
# ссылки: предложение пишется до того, как кто-либо откроет docs/, и без этих
# двух строк его пишут, не зная ни границы домена, ни инвариантов. Список
# короткий намеренно — длинный превращает context во второй дом фактов.
#
# Третий элемент — путь, по которому проверяется, есть ли документ в проекте
# вообще. Канон документов ставится отдельным плагином и может быть не подключён;
# требовать ссылку на файл, которого нет, значит требовать битую ссылку.
OPENSPEC_POINTERS = [
("passport", "docs/passport.md",
"граница домена и «чем НЕ является» останутся непрочитанными"),
("CLAUDE.md", "CLAUDE.md",
"инварианты и семантика гейта останутся непрочитанными"),
]
# --- Слепок чужого инструмента ----------------------------------------------
#
# Схема, перечень артефактов и версия, на которой это проверено, живут в OpenSpec
# и меняются без нашего участия; здесь они записаны, чтобы проверка шла без
# запуска node на каждом прогоне.
#
# Слепок стареет, и потому есть кто это замечает: `check` сравнивает major.minor
# установленного OpenSpec с OPENSPEC_CHECKED и, если они разошлись, говорит
# замечанием «форма не перепроверена». Перепроверяет команда `form` — она
# спрашивает сам инструмент и печатает, что разошлось. Патч-версия сравнением
# намеренно не берётся: форма конфига в ней не меняется, а замечание на каждый
# багфикс приучило бы пролистывать весь блок.
OPENSPEC_CHECKED = "1.5"
OPENSPEC_SCHEMA = "spec-driven"
# Артефакты схемы. Ключ `rules:` адресуется артефакту, и адресованный
# несуществующему **молча не действует** — ровно тот класс, ради которого вся
# проверка и заведена.
OPENSPEC_ARTIFACTS = ("proposal", "specs", "design", "tasks")
@dataclass
class Report:
errors: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
def error(self, msg: str) -> None:
self.errors.append(msg)
def note(self, msg: str) -> None:
self.notes.append(msg)
def skip(self, msg: str) -> None:
self.skipped.append(msg)
def fail(code: int, msg: str) -> NoReturn:
print(msg, file=sys.stderr)
sys.exit(code)
def openspec_cli(args: list[str]) -> str | None:
"""Спросить сам инструмент. None — его нет или он не ответил."""
try:
out = subprocess.run(
["openspec", *args], capture_output=True, text=True, timeout=30
)
except (FileNotFoundError, OSError, subprocess.SubprocessError):
return None
return out.stdout.strip() if out.returncode == 0 else None
def rules_keys(live: str) -> list[str]:
"""Имена артефактов, которым адресованы правила, — и только они.
Идём от строки `rules:` до следующего ключа нулевой колонки, а не ищем
отступ по всему файлу: блок `context: |` — литеральный скаляр, внутри него
строки вида «Language: Russian» и «av-dev:code-review» выглядят
ключами и дали бы находку на ровном месте. Проверено на живом конфиге,
который так и падал.
"""
out: list[str] = []
inside = False
for line in live.splitlines():
if not line.strip():
continue
if not line[0].isspace():
inside = line.startswith("rules:")
continue
if not inside:
continue
m = re.fullmatch(r" ([A-Za-z_-]+):\s*", line)
if m:
out.append(m.group(1))
return out
def rules_block(live: str, name: str) -> str:
"""Строки правил, адресованных одному артефакту.
Обход тот же, что у `rules_keys`, и по той же причине: искать по всему файлу
нельзя. Литеральный скаляр `context` называет `SHALL` уже в образце, поэтому
проверка «правила называют SHALL» грепом по файлу проходила при **пустом**
`rules.specs` — то есть молчала ровно в том случае, ради которого написана.
"""
out: list[str] = []
in_rules = False
in_name = False
for line in live.splitlines():
if not line.strip():
continue
if not line[0].isspace():
in_rules = line.startswith("rules:")
in_name = False
continue
if not in_rules:
continue
m = re.fullmatch(r" ([A-Za-z_-]+):\s*", line)
if m:
in_name = m.group(1) == name
continue
if in_name:
out.append(line)
return "\n".join(out)
def check_form(root: Path, rep: Report) -> None:
"""Настройка заведена и не осталась примером из коробки."""
os_dir = root / "openspec"
if not os_dir.is_dir():
# Здесь это отказ, а не «неприменимо»: скрипт принадлежит конвейеру, а
# конвейер без OpenSpec не работает вовсе. Тот же вопрос со стороны
# канона документов звучит иначе, и `docs.py` отвечает на него молчанием.
rep.error(
"нет openspec/ — там дом темы requirements (openspec/specs/) и "
f"настройка генерации артефактов; заводится `{OPENSPEC_INIT}`"
)
return
if (os_dir / "config.yml").is_file():
rep.error(
"openspec/config.yml — читается только config.yaml, и этот файл "
"останется незамеченным: настройка будет пустой, а выглядеть будет "
"заполненной"
)
path = os_dir / "config.yaml"
if not path.is_file():
rep.error(
"нет openspec/config.yaml — язык, правила именования capability и "
"придирки валидатора будут заново угадываться на каждом предложении"
)
return
text = path.read_text(encoding="utf-8")
live = "\n".join(
line for line in text.splitlines() if not line.lstrip().startswith("#")
)
keys = set(re.findall(r"(?m)^([A-Za-z_]+):", live))
schema = re.search(r"(?m)^schema:\s*(\S+)", live)
if schema is None:
rep.error(
f"в openspec/config.yaml нет ключа schema — ожидается {OPENSPEC_SCHEMA}"
)
elif schema.group(1) != OPENSPEC_SCHEMA:
rep.error(
f"schema в openspec/config.yaml — {schema.group(1)}, а форма описана "
f"для {OPENSPEC_SCHEMA}"
)
if "context" not in keys:
rep.error(
"в openspec/config.yaml нет ключа context: файл остался примером из "
"коробки — предложение пишется без языка, правил именования "
"capability и адресов документов проекта"
)
else:
for pointer, where, why in OPENSPEC_POINTERS:
if not (root / where).exists():
rep.skip(
f"{where} в проекте нет — ссылка на него в context не "
f"требуется. Документы канона ведёт отдельный плагин "
f"(av-dev-docs), и без него конвейер работает вслепую"
)
continue
if pointer not in live:
rep.error(f"openspec/config.yaml не называет {pointer}{why}")
if "rules" not in keys or "specs" not in rules_keys(live):
rep.error(
"в openspec/config.yaml нет rules.specs — придирки валидатора "
"нигде не записаны, и каждое предложение узнаёт их отказом"
)
elif "SHALL" not in rules_block(live, "specs"):
rep.error(
"rules.specs в openspec/config.yaml не называет SHALL — "
"требование без этого литерала валидатор отвергает, а правило "
"проекта об этом молчит"
)
# Ключ под rules: — имя артефакта схемы. Опечатка или устаревшее имя не
# ломает ничего видимого: правила просто не применяются, а конфиг выглядит
# написанным.
for name in rules_keys(live):
if name not in OPENSPEC_ARTIFACTS:
rep.error(
f"rules.{name} в openspec/config.yaml — такого артефакта у схемы "
f"{OPENSPEC_SCHEMA} нет ({', '.join(OPENSPEC_ARTIFACTS)}): правила "
f"под ним не применяются и молчат об этом"
)
def check_fresh(rep: Report) -> None:
"""Не устарел ли слепок формы.
Стоит один запуск `openspec --version` — десятые доли секунды. Перечень
артефактов и имя схемы отсюда не спрашиваются намеренно: они стоят втрое
дороже, а меняются только вместе с версией, и потому за ними ходит команда
`form`, а эта проверка говорит, когда её звать.
"""
got = openspec_cli(["--version"])
if got is None:
rep.skip(
"openspec не отвечает (нет на PATH?) — актуальность формы "
"config.yaml не проверялась"
)
return
installed = ".".join(got.split(".")[:2])
if installed != OPENSPEC_CHECKED:
rep.note(
f"форма openspec/config.yaml сверена с OpenSpec {OPENSPEC_CHECKED}, "
f"установлен {got}: перепроверить — `openspec.py form`. Пока не "
f"перепроверено, проверки формы судят по прежней схеме"
)
def report(rep: Report) -> int:
for msg in rep.errors:
print(f"ДРЕЙФ {msg}")
for msg in rep.notes:
print(f"ЗАМЕЧАНИЕ {msg}")
if rep.skipped:
print("\nНЕ ПРОВЕРЯЛОСЬ:")
for msg in rep.skipped:
print(f" {msg}")
print(
"\nМашина проверила форму: имя файла, схему, незаменённый пример, адреса\n"
"документов проекта и ключи rules против артефактов схемы. Чего она не\n"
"видит — **пересказ вместо ссылки**: утверждение, которое можно\n"
"опровергнуть, открыв другой файл проекта, от строки «открой такой-то\n"
"файл» она не отличает. Это суждение агента `doc-consistency` из плагина\n"
"канона документов; нет плагина — нет и этой проверки, и так и скажи."
)
if rep.errors:
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
return DRIFT
print("\nИтог: форма сошлась в механизируемой части.")
return OK
def cmd_check(args: argparse.Namespace) -> int:
root = Path(args.dir).resolve()
if not root.is_dir():
fail(ENV, f"нет каталога {root}")
rep = Report()
check_form(root, rep)
check_fresh(rep)
return report(rep)
def cmd_form(args: argparse.Namespace) -> int:
"""Перепроверить слепок формы по живому OpenSpec.
Ничего не правит и не трогает проект: спрашивает инструмент и печатает, что
разошлось с константами скрипта. Чинит человек — правкой констант, образца в
references/config-skeleton.md и записью в журнал версий канона, если форма
действительно поменялась.
"""
version = openspec_cli(["--version"])
if version is None:
fail(
ENV,
"openspec не отвечает: поставь его или проверь PATH — "
"перепроверять форму нечем",
)
raw = openspec_cli(["templates", "--json"])
if raw is None:
fail(ENV, "`openspec templates --json` не отработал — схему не спросить")
try:
artifacts = tuple(json.loads(raw))
except json.JSONDecodeError as exc:
fail(ENV, f"`openspec templates --json` отдал неразбираемое: {exc}")
print(f"OpenSpec установлен: {version}")
print(f"форма сверена с: {OPENSPEC_CHECKED}")
print(f"артефакты схемы: {', '.join(artifacts)}")
print(f"записано в скрипте: {', '.join(OPENSPEC_ARTIFACTS)}")
diffs: list[str] = []
if ".".join(version.split(".")[:2]) != OPENSPEC_CHECKED:
diffs.append(
f"версия: поднять OPENSPEC_CHECKED до "
f"{'.'.join(version.split('.')[:2])} — но только после того, как "
f"остальные строки этого отчёта сойдутся"
)
for name in artifacts:
if name not in OPENSPEC_ARTIFACTS:
diffs.append(
f"новый артефакт {name}: решить, нужны ли ему правила в rules, "
f"и добавить имя в OPENSPEC_ARTIFACTS"
)
for name in OPENSPEC_ARTIFACTS:
if name not in artifacts:
diffs.append(
f"артефакта {name} у схемы больше нет: правила под ним в конфигах "
f"проектов молчат — убрать из OPENSPEC_ARTIFACTS, из образца и "
f"записать в журнал версий канона"
)
print()
if not diffs:
print("Слепок сходится. Осталось глазами: не изменились ли придирки")
print("валидатора — их скрипт проверить не может, они проявляются только")
print("отказом `openspec validate --strict` на живой спеке.")
return OK
print("Разошлось:")
for line in diffs:
print(f" - {line}")
print()
print("Правится в трёх местах сразу: константы этого скрипта, образец")
print("`references/config-skeleton.md` и запись в журнал версий канона —")
print("иначе проекты останутся на прежней форме молча.")
return DRIFT
def main() -> int:
parser = argparse.ArgumentParser(
prog="openspec.py",
description="форма openspec/config.yaml: проверка проекта и сверка слепка",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_check = sub.add_parser("check", help="форма config.yaml в проекте")
p_check.add_argument("--dir", default=".", help="корень проекта")
p_check.set_defaults(func=cmd_check)
p_form = sub.add_parser(
"form", help="перепроверить слепок формы по живому OpenSpec"
)
p_form.set_defaults(func=cmd_form)
args = parser.parse_args()
try:
return args.func(args)
except SystemExit:
raise
except Exception as exc: # noqa: BLE001 — последний рубеж, код 4 по словарю
print(f"ВНУТРЕННИЙ СБОЙ: {exc}", file=sys.stderr)
return INTERNAL
if __name__ == "__main__":
sys.exit(main())