валидатор 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:
@@ -3247,3 +3247,30 @@ JJJ): у профиля обязан быть один правильный от
|
|||||||
кто о нём написал.** Канон описывал OpenSpec подробнее всех и потому казался
|
кто о нём написал.** Канон описывал 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. **Неявное допущение видно из другого дома, а не изнутри своего.** «Канон
|
||||||
|
есть» было верно всюду, где код лежал, и потому не читалось как допущение
|
||||||
|
вовсе. Переезд — самый дешёвый способ его обнаружить: не разбор, а смена
|
||||||
|
места, из которого на код смотрят.
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,11 @@
|
|||||||
`task-wording` (язык записей);
|
`task-wording` (язык записей);
|
||||||
- `session` — ритуал между спринтами и ведение спринта.
|
- `session` — ритуал между спринтами и ведение спринта.
|
||||||
- **av-dev-pipeline** — исполнение. **Требует OpenSpec и сам его заводит.**
|
- **av-dev-pipeline** — исполнение. **Требует OpenSpec и сам его заводит.**
|
||||||
- `openspec` — завести и настроить `openspec/` в проекте: `openspec init`,
|
- `openspec` — завести, настроить и **проверить** `openspec/` в проекте:
|
||||||
замена примера в `config.yaml` настройкой канонической формы. Каталог
|
`openspec init`, замена примера в `config.yaml` настройкой канонической
|
||||||
принадлежит конвейеру, а не канону: без конвейера он проекту не нужен;
|
формы, скрипт `openspec.py` (форма файла + сверка слепка с живой версией
|
||||||
|
инструмента). Каталог принадлежит конвейеру, а не канону: без конвейера он
|
||||||
|
проекту не нужен, и `docs.py` о нём молчит;
|
||||||
- `task-pipeline` — задача через полный цикл SDD, от постановки до коммита;
|
- `task-pipeline` — задача через полный цикл SDD, от постановки до коммита;
|
||||||
- `task-batch` — несколько задач разом, каждая в своём worktree;
|
- `task-batch` — несколько задач разом, каждая в своём worktree;
|
||||||
- `review-pipeline` — конвейер ревью **по темам**: документ проекта либо
|
- `review-pipeline` — конвейер ревью **по темам**: документ проекта либо
|
||||||
@@ -251,7 +253,7 @@ claude plugin uninstall <плагин>@av-dev-skills --scope project
|
|||||||
<plugin>/.claude-plugin/plugin.json манифест плагина
|
<plugin>/.claude-plugin/plugin.json манифест плагина
|
||||||
<plugin>/skills/<skill>/SKILL.md скилы (авто-обнаружение)
|
<plugin>/skills/<skill>/SKILL.md скилы (авто-обнаружение)
|
||||||
<plugin>/skills/<skill>/references/ что читается по ссылке из скилла
|
<plugin>/skills/<skill>/references/ что читается по ссылке из скилла
|
||||||
<plugin>/skills/<skill>/scripts/ tasks.py, docs.py
|
<plugin>/skills/<skill>/scripts/ tasks.py, docs.py, openspec.py
|
||||||
<plugin>/agents/ charter'ы сабагентов
|
<plugin>/agents/ charter'ы сабагентов
|
||||||
shared/ дома правил, общих для нескольких плагинов
|
shared/ дома правил, общих для нескольких плагинов
|
||||||
scripts/ проверки репозитория: копии, диаграммы, фронтматтеры
|
scripts/ проверки репозитория: копии, диаграммы, фронтматтеры
|
||||||
|
|||||||
@@ -49,18 +49,13 @@ ds="$CLAUDE_PLUGIN_ROOT/skills/canon/scripts/docs.py"
|
|||||||
|
|
||||||
python3 $ds check --dir <корень> [--base <rev>] # раскладка, ссылки, версия, сверки
|
python3 $ds check --dir <корень> [--base <rev>] # раскладка, ссылки, версия, сверки
|
||||||
python3 $ds version --dir <корень> # версия канона скрипта и проекта
|
python3 $ds version --dir <корень> # версия канона скрипта и проекта
|
||||||
python3 $ds openspec-form # форма config.yaml против живого OpenSpec
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**`openspec-form` зовут не на каждом прогоне, а когда о нём попросил `check`.**
|
**Формы `openspec/config.yaml` здесь больше нет.** Каталог принадлежит конвейеру,
|
||||||
Форма `openspec/config.yaml` описана в каноне слепком чужого инструмента — имя
|
и форму смотрит его скрипт — `av-dev-pipeline`, скилл `openspec`, команда
|
||||||
схемы и перечень артефактов, — и слепок стареет молча: OpenSpec переименует
|
`openspec.py check`. Проект работает по OpenSpec, а плагина конвейера нет — форму
|
||||||
артефакт, правила под старым именем перестанут применяться, а конфиг останется
|
не проверяет никто, и это надо сказать строкой доклада, а не считать, что она
|
||||||
выглядеть написанным. Поэтому `check` каждым прогоном сравнивает `major.minor`
|
верна.
|
||||||
установленного OpenSpec с тем, на котором форма сверялась, и при расхождении
|
|
||||||
даёт замечание с этой командой. Команда ничего не правит: она спрашивает
|
|
||||||
инструмент и печатает, что разошлось. **Чинится это в плагине, а не в проекте** —
|
|
||||||
константы `docs.py`, скелет в `skeletons.md` и запись в журнал версий канона.
|
|
||||||
|
|
||||||
**Коды выхода — тот же словарь, что у `tasks.py`:** 0 сошлось, 1 дрейф, 2 ошибка
|
**Коды выхода — тот же словарь, что у `tasks.py`:** 0 сошлось, 1 дрейф, 2 ошибка
|
||||||
употребления, 3 окружение, 4 внутренний сбой. Ветвись на коде, а не на тексте.
|
употребления, 3 окружение, 4 внутренний сбой. Ветвись на коде, а не на тексте.
|
||||||
|
|||||||
@@ -427,62 +427,23 @@ kebab-case.** Причина не эстетическая: имя файла с
|
|||||||
|
|
||||||
### `openspec/config.yaml`
|
### `openspec/config.yaml`
|
||||||
|
|
||||||
**Только нужды генерации артефактов** — язык, правила именования capability,
|
**Файл канону не принадлежит, и проверяет его тоже не канон.** Каталог
|
||||||
придирки валидатора RFC 2119 — плюс **адреса** документов канона. Правило ревью,
|
`openspec/` — предпосылка конвейера: без него не работают ни `opsx:propose`, ни
|
||||||
пересказ конвенций и инварианты сюда не пишутся: у них есть свои дома, и второй
|
ревью дизайна, ни сверка требований. Заводит его, настраивает и **проверяет
|
||||||
дом разойдётся на первой же правке.
|
форму** плагин `av-dev-pipeline`, скилл `openspec`: там образец файла, там же
|
||||||
|
скрипт `openspec.py check`. `docs.py` о файле не говорит ничего.
|
||||||
|
|
||||||
**Каталог `openspec/` принадлежит конвейеру, а не канону.** В нём дом темы
|
Канон называет его здесь по одной причине: `openspec/specs/` — **дом темы
|
||||||
`requirements`, и нужен он тому, кто по OpenSpec работает: без каталога не
|
`requirements`**, и без этой строки карта тем неполна. На форму самого
|
||||||
работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Заводит и
|
`config.yaml` канон не высказывается.
|
||||||
настраивает его скилл `av-dev-pipeline:openspec`; `init` и `adopt` его только
|
|
||||||
зовут. Команда (`openspec init --tools claude`) названа здесь поимённо потому,
|
|
||||||
что её печатает вывод `docs.py`, а адрес без команды заставляет искать её в
|
|
||||||
другом месте.
|
|
||||||
|
|
||||||
**Отсюда и односторонность: канон о файле высказывается, но его не требует.**
|
**Одно за каноном всё же остаётся, и это не форма, а единственный дом.** Блок
|
||||||
`docs.py check` проверяет форму, **если каталог есть**, и говорит
|
`context` — самое частое место для второго дома: он читается при порождении
|
||||||
«неприменимо», если его нет. Проект без конвейера живёт без OpenSpec законно, и
|
каждого артефакта, туда удобно дописать «чтобы агент знал», и так заводятся копии
|
||||||
отказом это быть не может.
|
инвариантов, конвенций, состава гейта и правил ревью. Расходятся они молча.
|
||||||
|
Разрез: **утверждение, которое можно опровергнуть, открыв другой файл проекта, —
|
||||||
**Файл из коробки настройкой не является.** `openspec init` кладёт `config.yaml`,
|
пересказ; строка, которая говорит, какой файл открыть, — ссылка.** Машина этого
|
||||||
где и `context`, и `rules` лежат закомментированным примером. Такой файл читается
|
не различает; судит агент `doc-consistency`, и `config.yaml` у него во входе.
|
||||||
как настроенный — он есть, он валиден, у него правильное имя, — а работает как
|
|
||||||
пустой: предложение пишется без языка, без правил именования 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).
|
|
||||||
|
|
||||||
## Правило единственного дома
|
## Правило единственного дома
|
||||||
|
|
||||||
@@ -578,7 +539,7 @@ OpenSpec переименует артефакт или сменит схему
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"canon": 9,
|
"canon": 10,
|
||||||
"migrations": "internal/store/migrations"
|
"migrations": "internal/store/migrations"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
## Версия 9 — 2026-08-09
|
||||||
|
|
||||||
OpenSpec уехал в конвейер. Каталог `openspec/` версией 7 был объявлен слотом
|
OpenSpec уехал в конвейер. Каталог `openspec/` версией 7 был объявлен слотом
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ severity стоит здесь, а не выводится каждым прох
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"canon": 9
|
"canon": 10
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NoReturn
|
from typing import NoReturn
|
||||||
|
|
||||||
CANON_VERSION = 9
|
CANON_VERSION = 10
|
||||||
|
|
||||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||||
|
|
||||||
@@ -76,40 +76,6 @@ DOC_EXTRA = {
|
|||||||
"adr": {"template.md": "шаблон записи ADR"},
|
"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/ и каталог задач. Формы у них скрипт не проверяет, и по разным
|
# Служебное в docs/ и каталог задач. Формы у них скрипт не проверяет, и по разным
|
||||||
# причинам: `.pm.json` не markdown, а `tasks/` **принадлежит другому плагину** —
|
# причинам: `.pm.json` не markdown, а `tasks/` **принадлежит другому плагину** —
|
||||||
# `av-dev-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:
|
def check_capabilities(root: Path, rep: Report) -> None:
|
||||||
specs = root / "openspec" / "specs"
|
specs = root / "openspec" / "specs"
|
||||||
text = doc_text(root, "architecture")
|
text = doc_text(root, "architecture")
|
||||||
@@ -750,10 +563,11 @@ def report(rep: Report) -> int:
|
|||||||
print(f" {msg}")
|
print(f" {msg}")
|
||||||
|
|
||||||
print(
|
print(
|
||||||
"\nМашина проверила раскладку, имена файлов, ссылки, версию, форму\n"
|
"\nМашина проверила раскладку, имена файлов, ссылки, версию и две\n"
|
||||||
"openspec/config.yaml и две сверки с кодом. Согласованность документов\n"
|
"сверки с кодом. Форму openspec/config.yaml она не проверяет: каталог\n"
|
||||||
"между собой и с кодом она не проверяет — как и то, ссылается ли\n"
|
"принадлежит конвейеру, и форму смотрит его скрипт\n"
|
||||||
"config.yaml на документы или пересказывает их. Это суждение агентов\n"
|
"(`av-dev-pipeline:openspec`, команда `openspec.py check`). Согласованность\n"
|
||||||
|
"документов между собой и с кодом — тоже не её: это суждение агентов\n"
|
||||||
"`doc-consistency` (документ ↔ документ ↔ openspec) и `doc-code-drift`\n"
|
"`doc-consistency` (документ ↔ документ ↔ openspec) и `doc-code-drift`\n"
|
||||||
"(документ ↔ код)."
|
"(документ ↔ код)."
|
||||||
)
|
)
|
||||||
@@ -779,7 +593,6 @@ def cmd_check(args: argparse.Namespace) -> int:
|
|||||||
check_slugs(root, rep)
|
check_slugs(root, rep)
|
||||||
check_links(root, rep)
|
check_links(root, rep)
|
||||||
check_placeholders_and_debt(root, rep)
|
check_placeholders_and_debt(root, rep)
|
||||||
check_openspec(root, rep)
|
|
||||||
check_capabilities(root, rep)
|
check_capabilities(root, rep)
|
||||||
check_migrations(root, cfg, args.base, rep)
|
check_migrations(root, cfg, args.base, rep)
|
||||||
return report(rep)
|
return report(rep)
|
||||||
@@ -794,71 +607,6 @@ def cmd_version(args: argparse.Namespace) -> int:
|
|||||||
return OK
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="docs.py",
|
prog="docs.py",
|
||||||
@@ -875,12 +623,6 @@ def main() -> int:
|
|||||||
p_ver.add_argument("--dir", default=".", help="корень проекта")
|
p_ver.add_argument("--dir", default=".", help="корень проекта")
|
||||||
p_ver.set_defaults(func=cmd_version)
|
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()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
return args.func(args)
|
return args.func(args)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: openspec
|
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 в проекте
|
# OpenSpec в проекте
|
||||||
@@ -10,9 +10,11 @@ description: "Завести и настроить OpenSpec в проекте
|
|||||||
не остаётся дома. Поэтому заводит и настраивает его этот плагин — тот, кто по
|
не остаётся дома. Поэтому заводит и настраивает его этот плагин — тот, кто по
|
||||||
OpenSpec и работает.
|
OpenSpec и работает.
|
||||||
|
|
||||||
Канон документов о файле всё ещё высказывается, но односторонне: `docs.py check`
|
Канон документов о файле не высказывается вовсе: `docs.py` его не открывает и об
|
||||||
проверяет форму `config.yaml`, **если каталог есть**, и молчит, если его нет.
|
его отсутствии молчит. Проект без конвейера живёт без OpenSpec законно, и
|
||||||
Проект без конвейера живёт без OpenSpec законно.
|
проверять там нечего. За каноном остаётся одно — **единственный дом**: не
|
||||||
|
пересказан ли в `context` документ, у которого есть свой файл. Это суждение, а не
|
||||||
|
форма, и смотрит его агент.
|
||||||
|
|
||||||
## Два шага, и второй важнее первого
|
## Два шага, и второй важнее первого
|
||||||
|
|
||||||
@@ -54,28 +56,55 @@ openspec init --tools claude
|
|||||||
|
|
||||||
Два адреса обязательны — `docs/passport.md` и `CLAUDE.md`: предложение пишется до
|
Два адреса обязательны — `docs/passport.md` и `CLAUDE.md`: предложение пишется до
|
||||||
того, как кто-либо откроет `docs/`, и без них его пишут, не зная ни границы
|
того, как кто-либо откроет `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`,
|
Схема (`spec-driven`) и перечень артефактов (`proposal`, `specs`, `design`,
|
||||||
`tasks`) — **состояние чужого инструмента**, а не наше решение. OpenSpec
|
`tasks`) — **состояние чужого инструмента**, а не наше решение. OpenSpec
|
||||||
переименует артефакт: правила под прежним именем перестанут применяться, конфиг
|
переименует артефакт: правила под прежним именем перестанут применяться, конфиг
|
||||||
останется выглядеть написанным, и молчат при этом все три стороны.
|
останется выглядеть написанным, и молчат при этом все три стороны.
|
||||||
|
|
||||||
Сторож — сравнение версий, и живёт он пока в `docs.py` плагина канона:
|
Сторож — сравнение версий. `check` каждым прогоном спрашивает `openspec
|
||||||
|
--version` (десятые доли секунды) и сравнивает `major.minor` с той версией, на
|
||||||
|
которой форма сверялась; разошлось — **замечание**, не отказ, с именем команды.
|
||||||
|
Патч-версия в сравнение не берётся намеренно: формы она не меняет, а нагоняй на
|
||||||
|
каждый багфикс приучает пролистывать весь блок.
|
||||||
|
|
||||||
```
|
Перепроверяет `openspec.py form`: он спрашивает `openspec templates --json`, то
|
||||||
python3 <канон>/skills/canon/scripts/docs.py openspec-form
|
есть перечень артефактов текущей схемы, и печатает, что разошлось с константами.
|
||||||
```
|
Дорогой вызов вынесен из `check` сознательно — он стоит втрое дороже опроса
|
||||||
|
версии, а ответ меняется только вместе с версией. **Чинится расхождение в
|
||||||
`check` каждым прогоном сравнивает `major.minor` установленного OpenSpec с той
|
плагине, а не в проекте:** константы скрипта, образец
|
||||||
версией, на которой форма сверялась, и при расхождении просит эту команду. Она
|
[references/config-skeleton.md](references/config-skeleton.md) и запись в журнал
|
||||||
ничего не правит — спрашивает инструмент и печатает, что разошлось. **Чинится
|
версий канона.
|
||||||
расхождение в плагине, а не в проекте.**
|
|
||||||
|
|
||||||
Плагина канона в проекте нет — сторожа тоже нет, и это надо назвать строкой, а не
|
|
||||||
считать, что форма верна.
|
|
||||||
|
|
||||||
## Кто зовёт этот скилл
|
## Кто зовёт этот скилл
|
||||||
|
|
||||||
@@ -95,3 +124,6 @@ python3 <канон>/skills/canon/scripts/docs.py openspec-form
|
|||||||
`context` только на них ссылаются.
|
`context` только на них ссылаются.
|
||||||
- **Не чинит расхождение формы с версией OpenSpec в проекте.** Оно чинится в
|
- **Не чинит расхождение формы с версией OpenSpec в проекте.** Оно чинится в
|
||||||
плагине: константы скрипта, образец здесь, запись в журнал версий канона.
|
плагине: константы скрипта, образец здесь, запись в журнал версий канона.
|
||||||
|
- **Не судит, ссылается `context` на документы или пересказывает их.** Машине
|
||||||
|
этот разрез не виден; его смотрит агент `doc-consistency` из плагина канона.
|
||||||
|
Плагина нет — эту проверку не делает никто, и так и скажи.
|
||||||
|
|||||||
@@ -69,11 +69,12 @@ rules:
|
|||||||
документации** — потому и записаны дословно: без них каждое второе предложение
|
документации** — потому и записаны дословно: без них каждое второе предложение
|
||||||
узнаёт их падением `openspec validate --strict`. Блок `context` проект
|
узнаёт их падением `openspec validate --strict`. Блок `context` проект
|
||||||
дополняет своим (стек, разведка, особенности домена), но **адреса паспорта и
|
дополняет своим (стек, разведка, особенности домена), но **адреса паспорта и
|
||||||
`CLAUDE.md` обязательны** — их отсутствие `docs.py check` называет отказом.
|
`CLAUDE.md` обязательны** — отсутствие адреса к существующему документу
|
||||||
|
`openspec.py check` называет отказом.
|
||||||
|
|
||||||
**Ключи под `rules:` — имена артефактов схемы**, а не свободные слова:
|
**Ключи под `rules:` — имена артефактов схемы**, а не свободные слова:
|
||||||
`proposal`, `specs`, `design`, `tasks`. Правило под чужим именем не применяется
|
`proposal`, `specs`, `design`, `tasks`. Правило под чужим именем не применяется
|
||||||
и об этом не сообщает, поэтому `rules.spec` вместо `rules.specs` даёт конфиг,
|
и об этом не сообщает, поэтому `rules.spec` вместо `rules.specs` даёт конфиг,
|
||||||
выглядящий написанным и не работающий; `docs.py check` такой ключ называет.
|
выглядящий написанным и не работающий; `openspec.py check` такой ключ называет.
|
||||||
Перечень артефактов задаёт OpenSpec, а не канон, — за его актуальностью следит
|
Перечень артефактов задаёт OpenSpec, а не мы, — за его актуальностью следит
|
||||||
`docs.py openspec-form`.
|
`openspec.py form`.
|
||||||
|
|||||||
@@ -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())
|
||||||
+3
-2
@@ -56,8 +56,9 @@ quote-style = "double"
|
|||||||
|
|
||||||
[tool.pyrefly]
|
[tool.pyrefly]
|
||||||
project-includes = [
|
project-includes = [
|
||||||
"av-dev-pm/skills/tasks/scripts/tasks.py",
|
"av-dev-tasks/skills/tasks/scripts/tasks.py",
|
||||||
"av-dev-pm/skills/canon/scripts/docs.py",
|
"av-dev-docs/skills/canon/scripts/docs.py",
|
||||||
|
"av-dev-pipeline/skills/openspec/scripts/openspec.py",
|
||||||
"scripts/copies.py",
|
"scripts/copies.py",
|
||||||
"scripts/diagrams.py",
|
"scripts/diagrams.py",
|
||||||
"scripts/frontmatter.py",
|
"scripts/frontmatter.py",
|
||||||
|
|||||||
Reference in New Issue
Block a user