From fdadfb65acc9b360edfc28aa2d365986aaf9229d Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 9 Aug 2026 14:28:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20config.yaml=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5?= =?UTF-8?q?=D1=85=D0=B0=D0=BB=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D0=B9=D0=B5=D1=80:=20=D1=83=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=D0=B0=20=D1=81=D0=B2=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение 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) --- DECISIONS.md | 27 ++ README.md | 10 +- av-dev-docs/skills/canon/SKILL.md | 15 +- av-dev-docs/skills/canon/references/canon.md | 71 +--- .../skills/canon/references/changelog.md | 40 ++ .../skills/canon/references/skeletons.md | 2 +- av-dev-docs/skills/canon/scripts/docs.py | 270 +------------ av-dev-pipeline/skills/openspec/SKILL.md | 68 +++- .../openspec/references/config-skeleton.md | 9 +- .../skills/openspec/scripts/openspec.py | 375 ++++++++++++++++++ pyproject.toml | 5 +- 11 files changed, 534 insertions(+), 358 deletions(-) create mode 100644 av-dev-pipeline/skills/openspec/scripts/openspec.py diff --git a/DECISIONS.md b/DECISIONS.md index 27e5f33..0ed00c0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -3247,3 +3247,30 @@ JJJ): у профиля обязан быть один правильный от кто о нём написал.** Канон описывал OpenSpec подробнее всех и потому казался его владельцем; работает по нему конвейер, и слот принадлежит конвейеру. + +## 52. Валидатор поехал за файлом: у конвейера появился свой скрипт (2026-08-09) + +**АЕАКК. Названный остаток закрыт, и закрыт он ценой первого скрипта в +конвейере.** Решение 51 отдало OpenSpec конвейеру и честно оставило хвост: +проверка формы `config.yaml` и сторож версии остались в `docs.py`, потому что +своего скрипта у пайплайна не было ни одного. Хвост оказался не косметическим — +это ровно то состояние, против которого написан весь канон: **у файла два +владельца, один заводит, другой проверяет**, и разойтись они могут молча. 252 +строки переехали в `av-dev-pipeline/skills/openspec/scripts/openspec.py`; в +`docs.py` от темы не осталось ни константы. + +**Переезд оплатился сразу, и не тем, чего ждали.** Прежняя проверка требовала, +чтобы `context` называл `docs/passport.md` и `CLAUDE.md`, **безусловно** — то есть +на проекте без канона документов требовала ссылку на несуществующий файл. Пока +проверка жила в скрипте канона, допущение «канон есть» было незаметным: скрипт +канона запускают там, где канон есть. В скрипте конвейера то же допущение стало +видно на первом же прогоне. Теперь адрес требуется только к документу, который в +проекте есть, а его отсутствие идёт строкой «не проверялось» с названной ценой. + +### Что из этого следует + +177. **Неявное допущение видно из другого дома, а не изнутри своего.** «Канон + есть» было верно всюду, где код лежал, и потому не читалось как допущение + вовсе. Переезд — самый дешёвый способ его обнаружить: не разбор, а смена + места, из которого на код смотрят. + diff --git a/README.md b/README.md index f75aa56..2d3d5a1 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,11 @@ `task-wording` (язык записей); - `session` — ритуал между спринтами и ведение спринта. - **av-dev-pipeline** — исполнение. **Требует OpenSpec и сам его заводит.** - - `openspec` — завести и настроить `openspec/` в проекте: `openspec init`, - замена примера в `config.yaml` настройкой канонической формы. Каталог - принадлежит конвейеру, а не канону: без конвейера он проекту не нужен; + - `openspec` — завести, настроить и **проверить** `openspec/` в проекте: + `openspec init`, замена примера в `config.yaml` настройкой канонической + формы, скрипт `openspec.py` (форма файла + сверка слепка с живой версией + инструмента). Каталог принадлежит конвейеру, а не канону: без конвейера он + проекту не нужен, и `docs.py` о нём молчит; - `task-pipeline` — задача через полный цикл SDD, от постановки до коммита; - `task-batch` — несколько задач разом, каждая в своём worktree; - `review-pipeline` — конвейер ревью **по темам**: документ проекта либо @@ -251,7 +253,7 @@ claude plugin uninstall <плагин>@av-dev-skills --scope project /.claude-plugin/plugin.json манифест плагина /skills//SKILL.md скилы (авто-обнаружение) /skills//references/ что читается по ссылке из скилла -/skills//scripts/ tasks.py, docs.py +/skills//scripts/ tasks.py, docs.py, openspec.py /agents/ charter'ы сабагентов shared/ дома правил, общих для нескольких плагинов scripts/ проверки репозитория: копии, диаграммы, фронтматтеры diff --git a/av-dev-docs/skills/canon/SKILL.md b/av-dev-docs/skills/canon/SKILL.md index e8ac96f..46c610b 100644 --- a/av-dev-docs/skills/canon/SKILL.md +++ b/av-dev-docs/skills/canon/SKILL.md @@ -49,18 +49,13 @@ ds="$CLAUDE_PLUGIN_ROOT/skills/canon/scripts/docs.py" python3 $ds check --dir <корень> [--base ] # раскладка, ссылки, версия, сверки python3 $ds version --dir <корень> # версия канона скрипта и проекта -python3 $ds openspec-form # форма config.yaml против живого OpenSpec ``` -**`openspec-form` зовут не на каждом прогоне, а когда о нём попросил `check`.** -Форма `openspec/config.yaml` описана в каноне слепком чужого инструмента — имя -схемы и перечень артефактов, — и слепок стареет молча: OpenSpec переименует -артефакт, правила под старым именем перестанут применяться, а конфиг останется -выглядеть написанным. Поэтому `check` каждым прогоном сравнивает `major.minor` -установленного OpenSpec с тем, на котором форма сверялась, и при расхождении -даёт замечание с этой командой. Команда ничего не правит: она спрашивает -инструмент и печатает, что разошлось. **Чинится это в плагине, а не в проекте** — -константы `docs.py`, скелет в `skeletons.md` и запись в журнал версий канона. +**Формы `openspec/config.yaml` здесь больше нет.** Каталог принадлежит конвейеру, +и форму смотрит его скрипт — `av-dev-pipeline`, скилл `openspec`, команда +`openspec.py check`. Проект работает по OpenSpec, а плагина конвейера нет — форму +не проверяет никто, и это надо сказать строкой доклада, а не считать, что она +верна. **Коды выхода — тот же словарь, что у `tasks.py`:** 0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение, 4 внутренний сбой. Ветвись на коде, а не на тексте. diff --git a/av-dev-docs/skills/canon/references/canon.md b/av-dev-docs/skills/canon/references/canon.md index 148753c..30bdeb0 100644 --- a/av-dev-docs/skills/canon/references/canon.md +++ b/av-dev-docs/skills/canon/references/canon.md @@ -427,62 +427,23 @@ kebab-case.** Причина не эстетическая: имя файла с ### `openspec/config.yaml` -**Только нужды генерации артефактов** — язык, правила именования capability, -придирки валидатора RFC 2119 — плюс **адреса** документов канона. Правило ревью, -пересказ конвенций и инварианты сюда не пишутся: у них есть свои дома, и второй -дом разойдётся на первой же правке. +**Файл канону не принадлежит, и проверяет его тоже не канон.** Каталог +`openspec/` — предпосылка конвейера: без него не работают ни `opsx:propose`, ни +ревью дизайна, ни сверка требований. Заводит его, настраивает и **проверяет +форму** плагин `av-dev-pipeline`, скилл `openspec`: там образец файла, там же +скрипт `openspec.py check`. `docs.py` о файле не говорит ничего. -**Каталог `openspec/` принадлежит конвейеру, а не канону.** В нём дом темы -`requirements`, и нужен он тому, кто по OpenSpec работает: без каталога не -работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Заводит и -настраивает его скилл `av-dev-pipeline:openspec`; `init` и `adopt` его только -зовут. Команда (`openspec init --tools claude`) названа здесь поимённо потому, -что её печатает вывод `docs.py`, а адрес без команды заставляет искать её в -другом месте. +Канон называет его здесь по одной причине: `openspec/specs/` — **дом темы +`requirements`**, и без этой строки карта тем неполна. На форму самого +`config.yaml` канон не высказывается. -**Отсюда и односторонность: канон о файле высказывается, но его не требует.** -`docs.py check` проверяет форму, **если каталог есть**, и говорит -«неприменимо», если его нет. Проект без конвейера живёт без OpenSpec законно, и -отказом это быть не может. - -**Файл из коробки настройкой не является.** `openspec init` кладёт `config.yaml`, -где и `context`, и `rules` лежат закомментированным примером. Такой файл читается -как настроенный — он есть, он валиден, у него правильное имя, — а работает как -пустой: предложение пишется без языка, без правил именования capability и без -знания, где лежит граница домена. Это ровно тот класс, против которого написан -весь канон, и потому здесь он проверяется машиной, а не чтением. - -Проверяется пять вещей, и каждая — про молчащий пробел, а не про вкус: - -1. **`openspec/` есть.** Нет — проверка неприменима, и это не отказ: каталог - нужен конвейеру, а не канону. Остальные четыре идут только при живом каталоге. -2. **Имя файла `config.yaml`.** `config.yml` OpenSpec не читает и об этом не - сообщает: настройка, написанная в файл с таким именем, пропадает целиком. -3. **`context` и `rules.specs` не остались примером.** Правила для `specs` - обязаны называть `SHALL`: требование без этого литерала валидатор отвергает. -4. **`context` называет `passport` и `CLAUDE.md`.** Предложение пишется **до** - того, как кто-либо откроет `docs/`; без этих двух адресов его пишут, не зная - ни границы домена, ни инвариантов. -5. **Ключи под `rules:` — имена артефактов схемы** (`proposal`, `specs`, - `design`, `tasks`). Правило, адресованное несуществующему артефакту, не - применяется и об этом молчит: `rules.spec` вместо `rules.specs` — конфиг, - выглядящий написанным и не работающий. - -**Схема и перечень артефактов — слепок чужого инструмента, и он стареет.** -OpenSpec переименует артефакт или сменит схему — правила под прежним именем -перестанут действовать молча, а канон будет продолжать требовать прежнее. -Поэтому за свежестью слепка следит машина: `check` сравнивает `major.minor` -установленного OpenSpec с версией, на которой форма сверялась, и при расхождении -даёт **замечание** (не отказ: патч-версии формы не меняют, а нагоняй на каждый -багфикс приучает пролистывать блок). Перепроверяет `docs.py openspec-form` — он -спрашивает сам инструмент и печатает, что разошлось. **Чинится это в плагине, а -не в проекте:** константы скрипта, скелет и запись в журнал версий канона. - -Шестого — «нет ли здесь пересказа» — машина не проверяет: отличить ссылку от -пересказа она не умеет. Это работа `doc-consistency`, и раздел «Что проверяет -машина, а что человек» называет её строкой. - -Форма — [skeletons.md](skeletons.md). +**Одно за каноном всё же остаётся, и это не форма, а единственный дом.** Блок +`context` — самое частое место для второго дома: он читается при порождении +каждого артефакта, туда удобно дописать «чтобы агент знал», и так заводятся копии +инвариантов, конвенций, состава гейта и правил ревью. Расходятся они молча. +Разрез: **утверждение, которое можно опровергнуть, открыв другой файл проекта, — +пересказ; строка, которая говорит, какой файл открыть, — ссылка.** Машина этого +не различает; судит агент `doc-consistency`, и `config.yaml` у него во входе. ## Правило единственного дома @@ -578,7 +539,7 @@ OpenSpec переименует артефакт или сменит схему ```json { - "canon": 9, + "canon": 10, "migrations": "internal/store/migrations" } ``` diff --git a/av-dev-docs/skills/canon/references/changelog.md b/av-dev-docs/skills/canon/references/changelog.md index 2d2c522..660a498 100644 --- a/av-dev-docs/skills/canon/references/changelog.md +++ b/av-dev-docs/skills/canon/references/changelog.md @@ -13,6 +13,46 @@ upgrade` идёт по записям снизу вверх от версии п --- +## Версия 10 — 2026-08-09 + +Проверка формы `config.yaml` ушла к тому, кто файл заводит. Версия 9 перенесла в +конвейер настройку OpenSpec и честно назвала остаток: форма и сторож версии +остались в `docs.py`, то есть у файла было два плагина — один заводит, другой +проверяет. Остаток закрыт. + +**Что появилось.** Скрипт `openspec.py` в скилле `av-dev-pipeline:openspec`, две +команды: `check --dir <корень>` — форма в проекте, `form` — сверка слепка с живым +OpenSpec. Коды выхода те же, что у `docs.py` и `tasks.py`. + +**Что удалено из `docs.py`.** Константы `OPENSPEC_*`, проверка формы, сторож +версии и подкоманда `openspec-form` — 252 строки. Скрипт канона про +`openspec/config.yaml` не говорит теперь ничего; `openspec/specs/` он по-прежнему +знает, потому что это дом темы `requirements` и часть карты тем. + +**Что стало лучше по дороге.** Адреса `docs/passport.md` и `CLAUDE.md` требуются +теперь **только к тем документам, которые в проекте есть**. Прежняя проверка +требовала их безусловно, то есть на проекте без канона документов требовала +битую ссылку. Теперь отсутствие документа — строка «не проверялось» с указанием, +что без канона конвейер работает вслепую. + +**Что осталось за каноном.** Один вопрос, и это не форма: не пересказан ли в +`context` документ, у которого есть свой дом. Разрез — утверждение, опровергаемое +открытием другого файла, против строки «открой такой-то файл»; машине он не +виден, судит агент `doc-consistency`, и `config.yaml` у него во входе. + +**Что сделать проекту.** + +1. Заменить в гейте и в скриптах `docs.py openspec-form` на `openspec.py form`. + Подкоманды больше нет: прежний вызов упадёт ошибкой употребления (код 2), а не + промолчит. +2. **Добавить в гейт шаг `openspec.py check`, если проект работает по OpenSpec.** + Форму раньше проверял `docs.py check` заодно; теперь он о ней молчит, и без + отдельного шага незаменённый пример в `config.yaml` перестанет ловиться. Это + главная потеря этого повышения, и она тихая. +3. Проект по OpenSpec без установленного `av-dev-pipeline` — форму не проверяет + никто. Либо поставить плагин, либо назвать это принятым риском вслух. +4. `docs/.pm.json`: `"canon": 10`. + ## Версия 9 — 2026-08-09 OpenSpec уехал в конвейер. Каталог `openspec/` версией 7 был объявлен слотом diff --git a/av-dev-docs/skills/canon/references/skeletons.md b/av-dev-docs/skills/canon/references/skeletons.md index 4107049..df46cf5 100644 --- a/av-dev-docs/skills/canon/references/skeletons.md +++ b/av-dev-docs/skills/canon/references/skeletons.md @@ -431,7 +431,7 @@ severity стоит здесь, а не выводится каждым прох ```json { - "canon": 9 + "canon": 10 } ``` diff --git a/av-dev-docs/skills/canon/scripts/docs.py b/av-dev-docs/skills/canon/scripts/docs.py index 70c483f..403146f 100644 --- a/av-dev-docs/skills/canon/scripts/docs.py +++ b/av-dev-docs/skills/canon/scripts/docs.py @@ -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) diff --git a/av-dev-pipeline/skills/openspec/SKILL.md b/av-dev-pipeline/skills/openspec/SKILL.md index bb5006f..c65a300 100644 --- a/av-dev-pipeline/skills/openspec/SKILL.md +++ b/av-dev-pipeline/skills/openspec/SKILL.md @@ -1,6 +1,6 @@ --- name: openspec -description: "Завести и настроить OpenSpec в проекте — openspec init --tools claude, замена закомментированного примера в openspec/config.yaml на настройку канонической формы (язык, правила именования capability, придирки валидатора, адреса паспорта и CLAUDE.md), проверка, что форма не разошлась с живой версией инструмента. Использовать, когда в проекте нет каталога openspec/, когда config.yaml остался примером из коробки, когда заводят новый проект или переводят чужой и дошли до шага OpenSpec, а также когда конвейер отказался работать без источника требований. Каталог openspec нужен именно конвейеру: без него не работают ни opsx:propose, ни ревью дизайна, ни сверка требований." +description: "Завести и настроить OpenSpec в проекте — openspec init --tools claude, замена закомментированного примера в openspec/config.yaml на настройку канонической формы (язык, правила именования capability, придирки валидатора, адреса паспорта и CLAUDE.md), проверка формы своим скриптом openspec.py (имя файла, схема, незаменённый пример, адреса документов, ключи rules против артефактов схемы) и сверка слепка с живой версией инструмента. Использовать, когда в проекте нет каталога openspec/, когда config.yaml остался примером из коробки, когда заводят новый проект или переводят чужой и дошли до шага OpenSpec, а также когда конвейер отказался работать без источника требований. Каталог openspec нужен именно конвейеру: без него не работают ни opsx:propose, ни ревью дизайна, ни сверка требований." --- # OpenSpec в проекте @@ -10,9 +10,11 @@ description: "Завести и настроить OpenSpec в проекте не остаётся дома. Поэтому заводит и настраивает его этот плагин — тот, кто по OpenSpec и работает. -Канон документов о файле всё ещё высказывается, но односторонне: `docs.py check` -проверяет форму `config.yaml`, **если каталог есть**, и молчит, если его нет. -Проект без конвейера живёт без OpenSpec законно. +Канон документов о файле не высказывается вовсе: `docs.py` его не открывает и об +его отсутствии молчит. Проект без конвейера живёт без OpenSpec законно, и +проверять там нечего. За каноном остаётся одно — **единственный дом**: не +пересказан ли в `context` документ, у которого есть свой файл. Это суждение, а не +форма, и смотрит его агент. ## Два шага, и второй важнее первого @@ -54,28 +56,55 @@ openspec init --tools claude Два адреса обязательны — `docs/passport.md` и `CLAUDE.md`: предложение пишется до того, как кто-либо откроет `docs/`, и без них его пишут, не зная ни границы -домена, ни инвариантов. Их отсутствие `docs.py check` называет отказом. +домена, ни инвариантов. Отсутствие адреса к **существующему** документу +`openspec.py check` называет отказом; документа нет в проекте — нет и требования. -## Форма сверяется с живым инструментом +## Инструмент + +``` +os="$CLAUDE_PLUGIN_ROOT/skills/openspec/scripts/openspec.py" + +python3 $os check --dir <корень> # форма config.yaml в проекте +python3 $os form # слепок формы против живого OpenSpec +``` + +**Коды выхода — общий словарь скриптов av-dev:** 0 сошлось, 1 дрейф, 2 ошибка +употребления, 3 окружение, 4 внутренний сбой. Ветвись на коде, а не на тексте. +Различать 1 и 3 обязательно: «форма разошлась» — рабочая ситуация, «openspec не +отвечает» — нерабочая. + +`check` проверяет пять вещей, и каждая — про молчащий пробел, а не про вкус: +каталог есть; имя именно `config.yaml` (`config.yml` OpenSpec не читает и об этом +не сообщает); `context` и `rules.specs` не остались примером, а правила называют +`SHALL`; `context` называет паспорт и `CLAUDE.md`; ключи под `rules:` — имена +артефактов схемы, а не свободные слова. + +**Адреса требуются только к тем документам, которые в проекте есть.** Канон +документов ставится отдельным плагином и может быть не подключён; требовать +ссылку на несуществующий файл значит требовать битую ссылку. Нет +`docs/passport.md` — проверка по нему идёт строкой «не проверялось», и там же +сказано, что без канона конвейер работает вслепую. + +### Форма сверяется с живым инструментом Схема (`spec-driven`) и перечень артефактов (`proposal`, `specs`, `design`, `tasks`) — **состояние чужого инструмента**, а не наше решение. OpenSpec переименует артефакт: правила под прежним именем перестанут применяться, конфиг останется выглядеть написанным, и молчат при этом все три стороны. -Сторож — сравнение версий, и живёт он пока в `docs.py` плагина канона: +Сторож — сравнение версий. `check` каждым прогоном спрашивает `openspec +--version` (десятые доли секунды) и сравнивает `major.minor` с той версией, на +которой форма сверялась; разошлось — **замечание**, не отказ, с именем команды. +Патч-версия в сравнение не берётся намеренно: формы она не меняет, а нагоняй на +каждый багфикс приучает пролистывать весь блок. -``` -python3 <канон>/skills/canon/scripts/docs.py openspec-form -``` - -`check` каждым прогоном сравнивает `major.minor` установленного OpenSpec с той -версией, на которой форма сверялась, и при расхождении просит эту команду. Она -ничего не правит — спрашивает инструмент и печатает, что разошлось. **Чинится -расхождение в плагине, а не в проекте.** - -Плагина канона в проекте нет — сторожа тоже нет, и это надо назвать строкой, а не -считать, что форма верна. +Перепроверяет `openspec.py form`: он спрашивает `openspec templates --json`, то +есть перечень артефактов текущей схемы, и печатает, что разошлось с константами. +Дорогой вызов вынесен из `check` сознательно — он стоит втрое дороже опроса +версии, а ответ меняется только вместе с версией. **Чинится расхождение в +плагине, а не в проекте:** константы скрипта, образец +[references/config-skeleton.md](references/config-skeleton.md) и запись в журнал +версий канона. ## Кто зовёт этот скилл @@ -95,3 +124,6 @@ python3 <канон>/skills/canon/scripts/docs.py openspec-form `context` только на них ссылаются. - **Не чинит расхождение формы с версией OpenSpec в проекте.** Оно чинится в плагине: константы скрипта, образец здесь, запись в журнал версий канона. +- **Не судит, ссылается `context` на документы или пересказывает их.** Машине + этот разрез не виден; его смотрит агент `doc-consistency` из плагина канона. + Плагина нет — эту проверку не делает никто, и так и скажи. diff --git a/av-dev-pipeline/skills/openspec/references/config-skeleton.md b/av-dev-pipeline/skills/openspec/references/config-skeleton.md index 300d046..8933f28 100644 --- a/av-dev-pipeline/skills/openspec/references/config-skeleton.md +++ b/av-dev-pipeline/skills/openspec/references/config-skeleton.md @@ -69,11 +69,12 @@ rules: документации** — потому и записаны дословно: без них каждое второе предложение узнаёт их падением `openspec validate --strict`. Блок `context` проект дополняет своим (стек, разведка, особенности домена), но **адреса паспорта и -`CLAUDE.md` обязательны** — их отсутствие `docs.py check` называет отказом. +`CLAUDE.md` обязательны** — отсутствие адреса к существующему документу +`openspec.py check` называет отказом. **Ключи под `rules:` — имена артефактов схемы**, а не свободные слова: `proposal`, `specs`, `design`, `tasks`. Правило под чужим именем не применяется и об этом не сообщает, поэтому `rules.spec` вместо `rules.specs` даёт конфиг, -выглядящий написанным и не работающий; `docs.py check` такой ключ называет. -Перечень артефактов задаёт OpenSpec, а не канон, — за его актуальностью следит -`docs.py openspec-form`. +выглядящий написанным и не работающий; `openspec.py check` такой ключ называет. +Перечень артефактов задаёт OpenSpec, а не мы, — за его актуальностью следит +`openspec.py form`. diff --git a/av-dev-pipeline/skills/openspec/scripts/openspec.py b/av-dev-pipeline/skills/openspec/scripts/openspec.py new file mode 100644 index 0000000..861cade --- /dev/null +++ b/av-dev-pipeline/skills/openspec/scripts/openspec.py @@ -0,0 +1,375 @@ +#!/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-pipeline: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_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()) diff --git a/pyproject.toml b/pyproject.toml index 7245f3f..ef1c51c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,8 +56,9 @@ quote-style = "double" [tool.pyrefly] project-includes = [ - "av-dev-pm/skills/tasks/scripts/tasks.py", - "av-dev-pm/skills/canon/scripts/docs.py", + "av-dev-tasks/skills/tasks/scripts/tasks.py", + "av-dev-docs/skills/canon/scripts/docs.py", + "av-dev-pipeline/skills/openspec/scripts/openspec.py", "scripts/copies.py", "scripts/diagrams.py", "scripts/frontmatter.py",