валидатор config.yaml переехал в конвейер: у пайплайна свой скрипт

Решение 51 отдало OpenSpec конвейеру и честно оставило хвост: проверка формы и
сторож версии остались в docs.py, потому что своего скрипта у пайплайна не было
ни одного. Хвост не косметический — это ровно то состояние, против которого
написан весь канон: у файла два владельца, один заводит, другой проверяет, и
разойтись они могут молча.

252 строки переехали в av-dev-pipeline/skills/openspec/scripts/openspec.py: пять
проверок формы, сторож версии, сверка слепка с живым инструментом. Команды две —
check --dir <корень> и form; коды выхода общие со всеми скриптами av-dev. Из
docs.py удалены константы OPENSPEC_*, check_openspec, openspec_cli,
check_openspec_fresh, rules_keys и подкоманда openspec-form; про config.yaml он
больше не говорит ничего, кроме строки границы механизируемого — что форму
смотрит чужой скрипт. openspec/specs/ он по-прежнему знает: это дом темы
requirements и часть карты тем.

Переезд оплатился сразу, и не тем, чего ждали. Прежняя проверка требовала, чтобы
context называл docs/passport.md и CLAUDE.md, безусловно — то есть на проекте без
канона документов требовала ссылку на несуществующий файл. Пока код жил в скрипте
канона, допущение «канон есть» было незаметным: скрипт канона запускают там, где
канон есть. В скрипте конвейера то же допущение стало видно на первом прогоне.
Теперь адрес требуется только к существующему документу, отсутствие идёт строкой
«не проверялось» с названной ценой — без канона конвейер работает вслепую.

Заодно починен хвост от раскола плагинов: pyrefly project-includes в pyproject
всё ещё указывали на av-dev-pm. Линтер на явных файлах работал, а на обходе
проекта не проверял ничего.

Проверено пятью случаями: нет openspec (1), годный конфиг (0), опечатка в имени
артефакта под rules (1), проект без канона (0, с двумя строками «не
проверялось»), неизвестная команда (2).

Канон повышен до версии 10. Главное в записи — тихая потеря: форму раньше
проверял docs.py check заодно, теперь нужен отдельный шаг openspec.py check в
гейте, иначе незаменённый пример в config.yaml перестанет ловиться. Решение — 52.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-09 14:28:12 +03:00
co-authored by Claude Opus 5
parent 9453a218d1
commit fdadfb65ac
11 changed files with 534 additions and 358 deletions
+6 -264
View File
@@ -25,7 +25,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
CANON_VERSION = 9
CANON_VERSION = 10
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
@@ -76,40 +76,6 @@ DOC_EXTRA = {
"adr": {"template.md": "шаблон записи ADR"},
}
# Настройка OpenSpec. Команда заведения — она же в скилле init; здесь потому,
# что её печатает отказ, а отказ без команды заставляет искать её в другом месте.
OPENSPEC_INIT = "openspec init --tools claude"
# Адреса, которые обязан назвать блок context. Не пересказ документов, а именно
# ссылки: предложение пишется до того, как кто-либо откроет docs/, и без этих
# двух строк его пишут, не зная ни границы домена, ни инвариантов. Список
# короткий намеренно — длинный превращает context во второй дом фактов.
OPENSPEC_POINTERS = [
("passport", "граница домена и «чем НЕ является» останутся непрочитанными"),
("CLAUDE.md", "инварианты и семантика гейта останутся непрочитанными"),
]
# --- Форма config.yaml сверена с живым OpenSpec ------------------------------
#
# Три константы ниже — **слепок чужого инструмента**, а не наше решение. Схема,
# перечень артефактов и версия, на которой это проверено, живут в OpenSpec и
# меняются без нашего участия; здесь они записаны, чтобы проверка шла без запуска
# node на каждом прогоне.
#
# Слепок стареет, и потому есть кто, кто это замечает: `check` сравнивает
# major.minor установленного OpenSpec с OPENSPEC_CHECKED и, если они разошлись,
# говорит замечанием «форма не перепроверена». Перепроверяет `docs.py
# openspec-form` — он спрашивает сам инструмент и печатает, что разошлось.
# Патч-версия сравнением намеренно не берётся: форма конфига в ней не меняется, а
# замечание на каждый багфикс приучило бы пролистывать весь блок.
OPENSPEC_CHECKED = "1.5"
OPENSPEC_SCHEMA = "spec-driven"
# Артефакты схемы. Ключ `rules:` адресуется артефакту, и адресованный
# несуществующему **молча не действует** — ровно тот класс, ради которого вся
# проверка и заведена.
OPENSPEC_ARTIFACTS = ("proposal", "specs", "design", "tasks")
# Служебное в docs/ и каталог задач. Формы у них скрипт не проверяет, и по разным
# причинам: `.pm.json` не markdown, а `tasks/` **принадлежит другому плагину** —
# `av-dev-tasks`, со своим скриптом, своим конфигом и своей версией формата.
@@ -493,159 +459,6 @@ def doc_text(root: Path, name: str) -> str | None:
)
def rules_keys(live: str) -> list[str]:
"""Имена артефактов, которым адресованы правила, — и только они.
Идём от строки `rules:` до следующего ключа нулевой колонки, а не ищем
отступ по всему файлу: блок `context: |` — литеральный скаляр, внутри него
строки вида «Language: Russian» и «av-dev-pm:review-pipeline» выглядят
ключами и дали бы находку на ровном месте. Проверено на живом конфиге,
который так и падал.
"""
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_openspec(root: Path, rep: Report) -> None:
"""Настройка OpenSpec заведена и не осталась примером из коробки.
Разбираем текстом, а не YAML-парсером: у скриптов канона ноль внешних
зависимостей, а PyYAML в стандартной библиотеке нет. Всё, что проверяется
ниже, различимо построчно, и ложных срабатываний это не даёт: комментарии
отброшены, ключи верхнего уровня стоят в первой колонке.
"""
os_dir = root / "openspec"
if not os_dir.is_dir():
# Каталог принадлежит конвейеру, а не канону: там дом темы requirements
# и настройка генерации артефактов, и нужен он тому, кто по OpenSpec
# работает. Проект без конвейера живёт без него законно, поэтому здесь
# неприменимость, а не отказ. Заводит каталог скилл
# av-dev-pipeline:openspec; команда названа на случай, если плагина нет.
rep.skip(
"нет openspec/ — проверка неприменима. Каталог заводит скилл "
f"av-dev-pipeline:openspec (`{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, 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 — "
"требование без этого литерала валидатор отвергает, а правило "
"проекта об этом молчит"
)
# Ключ под 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"под ним не применяются и молчат об этом"
)
check_openspec_fresh(rep)
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 check_openspec_fresh(rep: Report) -> None:
"""Не устарел ли наш слепок формы config.yaml.
Стоит один запуск `openspec --version` — десятые доли секунды. Перечень
артефактов и имя схемы отсюда не спрашиваются намеренно: они стоят втрое
дороже, а меняются только вместе с версией, и потому за ними ходит отдельная
команда `openspec-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}: перепроверить — `docs.py openspec-form`. Пока не "
f"перепроверено, проверки формы судят по прежней схеме"
)
def check_capabilities(root: Path, rep: Report) -> None:
specs = root / "openspec" / "specs"
text = doc_text(root, "architecture")
@@ -750,10 +563,11 @@ def report(rep: Report) -> int:
print(f" {msg}")
print(
"\nМашина проверила раскладку, имена файлов, ссылки, версию, форму\n"
"openspec/config.yaml и две сверки с кодом. Согласованность документов\n"
"между собой и с кодом она не проверяет — как и то, ссылается ли\n"
"config.yaml на документы или пересказывает их. Это суждение агентов\n"
"\nМашина проверила раскладку, имена файлов, ссылки, версию и две\n"
"сверки с кодом. Форму openspec/config.yaml она не проверяет: каталог\n"
"принадлежит конвейеру, и форму смотрит его скрипт\n"
"(`av-dev-pipeline:openspec`, команда `openspec.py check`). Согласованность\n"
"документов между собой и с кодом — тоже не её: это суждение агентов\n"
"`doc-consistency` (документ ↔ документ ↔ openspec) и `doc-code-drift`\n"
"(документ ↔ код)."
)
@@ -779,7 +593,6 @@ 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)
return report(rep)
@@ -794,71 +607,6 @@ def cmd_version(args: argparse.Namespace) -> int:
return OK
def cmd_openspec_form(args: argparse.Namespace) -> int:
"""Перепроверить слепок формы config.yaml по живому OpenSpec.
Ничего не правит и не трогает проект: спрашивает инструмент и печатает, что
разошлось с константами скрипта. Чинит человек — правкой констант, скелета в
skeletons.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("`openspec/config.yaml` в skeletons.md и запись в changelog.md —")
print("иначе проекты останутся на прежней форме молча.")
return DRIFT
def main() -> int:
parser = argparse.ArgumentParser(
prog="docs.py",
@@ -875,12 +623,6 @@ def main() -> int:
p_ver.add_argument("--dir", default=".", help="корень проекта")
p_ver.set_defaults(func=cmd_version)
p_form = sub.add_parser(
"openspec-form",
help="перепроверить форму config.yaml по живому OpenSpec",
)
p_form.set_defaults(func=cmd_openspec_form)
args = parser.parse_args()
try:
return args.func(args)