init заводит openspec сам; конфиг стал слотом канона
Каталог openspec/ был предпосылкой, о которой канон говорил, но за которой не следил. openspec/specs/ объявлен домом темы requirements, config.yaml описан абзацем — а заводилось всё руками, и не проверялось ничего. Новый проект выходил из init с полным каноном документов и без каталога, без которого не работают ни opsx:propose, ни ревью дизайна, ни сверка требований. Теперь init делает openspec init --tools claude шагом 3, до первого документа, а adopt заводит его тем же способом, если на переводимом проекте его нет. Команда названа поимённо в трёх местах — скилле, каноне и отказе docs.py: отказ без команды заставляет искать её в другом месте. Файл из коробки оказался хуже отсутствующего, и потому проверяется машиной. openspec init кладёт config.yaml, где context и rules — закомментированный пример на английском. Такой файл читается как настроенный: он есть, он валиден, имя правильное. Работает он как пустой, и узнаётся это по уже написанному предложению — на другом языке, с capability по имени пакета, без единого SHALL. docs.py проверяет четыре вещи, каждая про молчащий пробел: каталог есть; имя именно config.yaml (config.yml OpenSpec не читает и об этом не сообщает); context и rules.specs не остались примером, а правила называют SHALL; context называет passport и CLAUDE.md. Последние два обязательны по порядку работы: предложение пишется до того, как кто-либо откроет docs/, и без этих строк его пишут, не зная ни границы домена, ни инвариантов. Форма конфига записана скелетом и сформулирована разрезом: утверждение, которое можно опровергнуть, открыв другой файл проекта, — пересказ; строка, которая говорит, какой файл открыть, — ссылка. Машина этот разрез не проверяет, отличить одно от другого она не умеет; он отдан doc-consistency отдельным абзацем правила «один факт — один дом», и config.yaml добавлен ему во вход. Место второго дома там самое частое: context читается при порождении каждого артефакта, туда удобно дописать «чтобы агент знал», и так заводятся копии инвариантов, конвенций, состава гейта и правил ревью. Образец лёг в канон, а не в конвейер, как планировало решение C: форма документа принадлежит владельцу канона документов, конвейер её читатель. Иначе av-dev-pipeline завёл бы описание файла, который заводит и проверяет av-dev-pm. Канон повышен до версии 7 с записью, выполнимой upgrade: завести openspec, привести config.yaml к скелету, вычистить из context пересказ, проверить имя файла, поднять номер в .pm.json. Проверка прогнана на четырёх фикстурах — свежий openspec init, два живых проекта и пустой каталог; отличает все четыре случая. Решение — 47. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
CANON_VERSION = 6
|
||||
CANON_VERSION = 7
|
||||
|
||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||
|
||||
@@ -76,6 +76,19 @@ DOC_EXTRA = {
|
||||
"adr": {"template.md": "шаблон записи ADR"},
|
||||
}
|
||||
|
||||
# Настройка OpenSpec. Команда заведения — она же в скилле init; здесь потому,
|
||||
# что её печатает отказ, а отказ без команды заставляет искать её в другом месте.
|
||||
OPENSPEC_INIT = "openspec init --tools claude"
|
||||
|
||||
# Адреса, которые обязан назвать блок context. Не пересказ документов, а именно
|
||||
# ссылки: предложение пишется до того, как кто-либо откроет docs/, и без этих
|
||||
# двух строк его пишут, не зная ни границы домена, ни инвариантов. Список
|
||||
# короткий намеренно — длинный превращает context во второй дом фактов.
|
||||
OPENSPEC_POINTERS = [
|
||||
("passport", "граница домена и «чем НЕ является» останутся непрочитанными"),
|
||||
("CLAUDE.md", "инварианты и семантика гейта останутся непрочитанными"),
|
||||
]
|
||||
|
||||
# Служебное в docs/ и каталог, который ведёт tasks.py. Оба процессные, но
|
||||
# проверок формы у них нет: .pm.json не markdown, tasks/ ведёт другой скрипт.
|
||||
NOT_DOCS = {".pm.json", "tasks"}
|
||||
@@ -455,6 +468,78 @@ def doc_text(root: Path, name: str) -> str | None:
|
||||
)
|
||||
|
||||
|
||||
def check_openspec(root: Path, rep: Report) -> None:
|
||||
"""Настройка OpenSpec заведена и не осталась примером из коробки.
|
||||
|
||||
Разбираем текстом, а не YAML-парсером: у скриптов канона ноль внешних
|
||||
зависимостей, а PyYAML в стандартной библиотеке нет. Всё, что проверяется
|
||||
ниже, различимо построчно, и ложных срабатываний это не даёт: комментарии
|
||||
отброшены, ключи верхнего уровня стоят в первой колонке.
|
||||
"""
|
||||
os_dir = root / "openspec"
|
||||
if not os_dir.is_dir():
|
||||
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("в openspec/config.yaml нет ключа schema — ожидается spec-driven")
|
||||
elif schema.group(1) != "spec-driven":
|
||||
rep.error(
|
||||
f"schema в openspec/config.yaml — {schema.group(1)}, а канон описан "
|
||||
f"для spec-driven"
|
||||
)
|
||||
|
||||
if "context" not in keys:
|
||||
rep.error(
|
||||
"в openspec/config.yaml нет ключа context: файл остался примером из "
|
||||
"коробки — предложение пишется без языка, правил именования "
|
||||
"capability и адресов документов проекта"
|
||||
)
|
||||
else:
|
||||
for pointer, why in OPENSPEC_POINTERS:
|
||||
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 — "
|
||||
"требование без этого литерала валидатор отвергает, а правило "
|
||||
"проекта об этом молчит"
|
||||
)
|
||||
|
||||
|
||||
def check_capabilities(root: Path, rep: Report) -> None:
|
||||
specs = root / "openspec" / "specs"
|
||||
text = doc_text(root, "architecture")
|
||||
@@ -588,10 +673,12 @@ def report(rep: Report) -> int:
|
||||
print(f" {msg}")
|
||||
|
||||
print(
|
||||
"\nМашина проверила раскладку, имена файлов, ссылки, версию и две сверки\n"
|
||||
"с кодом. Согласованность документов между собой и с кодом она не\n"
|
||||
"проверяет — это суждение агентов `doc-consistency` (документ ↔ документ\n"
|
||||
"↔ openspec) и `doc-code-drift` (документ ↔ код)."
|
||||
"\nМашина проверила раскладку, имена файлов, ссылки, версию, форму\n"
|
||||
"openspec/config.yaml и две сверки с кодом. Согласованность документов\n"
|
||||
"между собой и с кодом она не проверяет — как и то, ссылается ли\n"
|
||||
"config.yaml на документы или пересказывает их. Это суждение агентов\n"
|
||||
"`doc-consistency` (документ ↔ документ ↔ openspec) и `doc-code-drift`\n"
|
||||
"(документ ↔ код)."
|
||||
)
|
||||
if rep.errors:
|
||||
print(f"\nИтог: дрейф, {len(rep.errors)} пунктов.")
|
||||
@@ -615,6 +702,7 @@ def cmd_check(args: argparse.Namespace) -> int:
|
||||
check_slugs(root, rep)
|
||||
check_links(root, rep)
|
||||
check_placeholders_and_debt(root, rep)
|
||||
check_openspec(root, rep)
|
||||
check_capabilities(root, rep)
|
||||
check_migrations(root, cfg, args.base, rep)
|
||||
check_tasks(root, rep)
|
||||
|
||||
Reference in New Issue
Block a user