#!/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 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 live: rep.error( "в openspec/config.yaml нет rules.specs — придирки валидатора " "нигде не записаны, и каждое предложение узнаёт их отказом" ) elif "SHALL" not in live: 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())