- каталог скилла и все вызовы переименованы: префикс `doc-` называл материал, а скилл занят формой — раскладкой всех частей проекта и общим повышением версии, включая каталог задач; - README перестроен: `canon` вынесен из семейства документов отдельным блоком и отдельным узлом графа, правило префиксов переформулировано, у документов уточнено владение — содержимым, а не раскладкой; - заведена запись 2 журнала версий: в проекте ничего не переехало, но путь к `docs.py` и имя вызова живут в гейте и в `CLAUDE.md` проекта и сломаются молча; - прежние адреса в записи 1 и в журнале решений оставлены как есть: журнал описывает состояния, которые были, и задним числом не переписывается.
401 lines
20 KiB
Python
401 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""Форма `openspec/config.yaml`: проверка проекта и сверка слепка с инструментом.
|
||
|
||
Каталог `openspec/` — предпосылка **конвейера**, а не канона документов: без него
|
||
не работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Поэтому и
|
||
проверка формы живёт здесь, рядом со скиллом, который каталог заводит. Раньше она
|
||
жила в `docs.py`, у скилла канона, и у файла было два владельца: один заводит,
|
||
другой проверяет.
|
||
|
||
Проверяется то, что **молчит при поломке**. Файл из коробки хуже отсутствующего:
|
||
`openspec init` кладёт `config.yaml`, где `context` и `rules` — закомментированный
|
||
пример на английском. Он есть, он валиден, имя правильное — и читается как
|
||
настроенный, работая как пустой. Узнаётся это по уже написанному предложению.
|
||
|
||
Разбираем текстом, а не YAML-парсером: у скриптов ноль внешних зависимостей, а
|
||
PyYAML в стандартной библиотеке нет. Всё проверяемое различимо построчно,
|
||
комментарии отброшены, ключи верхнего уровня стоят в первой колонке.
|
||
|
||
Коды выхода — общий словарь скриптов av-dev; дом словаря и разбор «дрейф
|
||
против окружения» — av-dev/shared/axes.md. Значения — в константах ниже.
|
||
"""
|
||
|
||
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:canon"
|
||
)
|
||
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())
|