слияние: три плагина стали одним av-dev, скиллы получили префиксы
Каталоги, агенты и общие дома переехали в av-dev/; скиллы названы по прежнему плагину — doc-*, task-*, code-*, с двумя смысловыми именами вместо тавтологии: doc-sync вместо docs, task-track вместо tasks. Манифесты сведены к двум плагинам. Пространства имён вызовов и пути внутри дерева переписаны машинно; проза, которая называет прежние плагины отдельными, идёт следующим шагом.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: code-openspec
|
||||
description: "Завести и настроить OpenSpec в проекте — openspec init --tools claude, замена закомментированного примера в openspec/config.yaml на настройку канонической формы (язык, правила именования capability, придирки валидатора, адреса паспорта и CLAUDE.md), проверка формы своим скриптом openspec.py (имя файла, схема, незаменённый пример, адреса документов, ключи rules против артефактов схемы) и сверка слепка с живой версией инструмента. Использовать, когда в проекте нет каталога openspec/, когда config.yaml остался примером из коробки, когда заводят новый проект или переводят чужой и дошли до шага OpenSpec, а также когда конвейер отказался работать без источника требований. Каталог openspec нужен именно конвейеру: без него не работают ни opsx:propose, ни ревью дизайна, ни сверка требований."
|
||||
---
|
||||
|
||||
# OpenSpec в проекте
|
||||
|
||||
Каталог `openspec/` — **предпосылка конвейера**, а не канона документов. Без него
|
||||
не работают ни `opsx:propose`, ни ревью дизайна, ни `review-specs`: у требований
|
||||
не остаётся дома. Поэтому заводит и настраивает его этот плагин — тот, кто по
|
||||
OpenSpec и работает.
|
||||
|
||||
Канон документов о файле не высказывается вовсе: `docs.py` его не открывает и об
|
||||
его отсутствии молчит. Проект без конвейера живёт без OpenSpec законно, и
|
||||
проверять там нечего. За каноном остаётся одно — **единственный дом**: не
|
||||
пересказан ли в `context` документ, у которого есть свой файл. Это суждение, а не
|
||||
форма, и смотрит его агент.
|
||||
|
||||
## Два шага, и второй важнее первого
|
||||
|
||||
**1. Завести.**
|
||||
|
||||
```
|
||||
openspec init --tools claude
|
||||
```
|
||||
|
||||
Команда кладёт ещё `.claude/skills/openspec-*` и `.claude/commands/opsx/*` — это
|
||||
её нормальная работа, не трогай их.
|
||||
|
||||
**2. Заменить пример.** `openspec init` кладёт `config.yaml`, где `context` и
|
||||
`rules` — закомментированный пример на английском. **Файл из коробки хуже
|
||||
отсутствующего:** он есть, он валиден, имя правильное, — и читается как
|
||||
настроенный, работая как пустой. Узнаётся это по уже написанному предложению: на
|
||||
другом языке, с capability по имени пакета, без единого `SHALL`.
|
||||
|
||||
Пример **заменяется целиком** по образцу:
|
||||
[references/config-skeleton.md](references/config-skeleton.md).
|
||||
|
||||
## Что туда пишут, а что нет
|
||||
|
||||
**Это маршрутизатор, а не второй дом фактов.** Внутрь идёт ровно то, что нужно
|
||||
**в момент порождения артефакта** и чего в этот момент ещё никто не открыл: язык,
|
||||
правила именования capability, придирки валидатора и **адреса** документов
|
||||
проекта.
|
||||
|
||||
Сюда же — **требования к форме `proposal` и `design`**, на которых стоит чекпоинт
|
||||
скилла `av-dev:code-resolve`: объяснение человеку собирается из этих двух
|
||||
артефактов, и требование к ним обязано применяться в момент, когда их пишут, а не
|
||||
вспоминаться шагом позже. Образец их содержит.
|
||||
|
||||
Пересказ паспорта, инвариантов, конвенций и правил ревью сюда **не переносится**.
|
||||
Место для второго дома здесь самое частое: `context` читается при порождении
|
||||
каждого артефакта, туда удобно дописать «чтобы агент знал», и так заводятся копии
|
||||
инвариантов, состава гейта и правил выбора метки. Расходятся они молча, а
|
||||
замечают это в уже написанном предложении.
|
||||
|
||||
Разрез, по которому отличают одно от другого: **утверждение, которое можно
|
||||
опровергнуть, открыв другой файл проекта, — пересказ; строка, которая говорит,
|
||||
какой файл открыть, — ссылка.** Машина этот разрез не проверяет; его смотрит
|
||||
агент `doc-consistency` из плагина канона, когда тот подключён.
|
||||
|
||||
Два адреса обязательны — `docs/passport.md` и `CLAUDE.md`: предложение пишется до
|
||||
того, как кто-либо откроет `docs/`, и без них его пишут, не зная ни границы
|
||||
домена, ни инвариантов. Отсутствие адреса к **существующему** документу
|
||||
`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 не читает и об этом
|
||||
не сообщает); ключ `schema` называет ту схему, для которой форма описана;
|
||||
`context` и `rules.specs` не остались примером, **а `SHALL` назван именно внутри
|
||||
`rules.specs`** (в `context` он стоит и в образце, поэтому греп по файлу здесь
|
||||
ничего не значит); `context` называет паспорт и `CLAUDE.md`; ключи под `rules:` —
|
||||
имена артефактов схемы, а не свободные слова. Числа проверок здесь нет намеренно:
|
||||
оно протухает от каждой добавленной.
|
||||
|
||||
**Адреса требуются только к тем документам, которые в проекте есть.** Канон
|
||||
документов ставится отдельным плагином и может быть не подключён; требовать
|
||||
ссылку на несуществующий файл значит требовать битую ссылку. Нет
|
||||
`docs/passport.md` — проверка по нему идёт строкой «не проверялось», и там же
|
||||
сказано, что без канона конвейер работает вслепую.
|
||||
|
||||
### Форма сверяется с живым инструментом
|
||||
|
||||
Схема (`spec-driven`) и перечень артефактов (`proposal`, `specs`, `design`,
|
||||
`tasks`) — **состояние чужого инструмента**, а не наше решение. OpenSpec
|
||||
переименует артефакт: правила под прежним именем перестанут применяться, конфиг
|
||||
останется выглядеть написанным, и молчат при этом все три стороны.
|
||||
|
||||
Сторож — сравнение версий. `check` каждым прогоном спрашивает `openspec
|
||||
--version` (десятые доли секунды) и сравнивает `major.minor` с той версией, на
|
||||
которой форма сверялась; разошлось — **замечание**, не отказ, с именем команды.
|
||||
Патч-версия в сравнение не берётся намеренно: формы она не меняет, а нагоняй на
|
||||
каждый багфикс приучает пролистывать весь блок.
|
||||
|
||||
Перепроверяет `openspec.py form`: он спрашивает `openspec templates --json`, то
|
||||
есть перечень артефактов текущей схемы, и печатает, что разошлось с константами.
|
||||
Дорогой вызов вынесен из `check` сознательно — он стоит втрое дороже опроса
|
||||
версии, а ответ меняется только вместе с версией. **Чинится расхождение в
|
||||
плагине, а не в проекте:** константы скрипта, образец
|
||||
[references/config-skeleton.md](references/config-skeleton.md) и запись в журнал
|
||||
версий канона.
|
||||
|
||||
## Кто зовёт этот скилл
|
||||
|
||||
- `av-dev:doc-init` — шагом заведения нового проекта, до первого документа;
|
||||
- `av-dev:doc-canon` в режиме `adopt` — если на переводимом проекте каталога нет
|
||||
или `config.yaml` остался примером;
|
||||
- `av-dev:code-resolve` и `av-dev:code-review` — не вызовом по ходу, а отсылкой:
|
||||
OpenSpec у обоих жёсткая предпосылка, и на проекте без каталога оба посылают
|
||||
сюда вместо того, чтобы заводить его руками;
|
||||
- человек — когда конвейер отказался работать без источника требований.
|
||||
|
||||
**Копия.** Дом правила — `shared/plugin-boundary.md` в репозитории плагинов.
|
||||
Правится дом, а не этот файл.
|
||||
|
||||
<!-- копия: граница-плагинов из av-dev/shared/plugin-boundary.md -->
|
||||
|
||||
Плагины `av-dev` ставятся порознь, и ни один не вправе считать, что сосед на
|
||||
месте.
|
||||
|
||||
**Чужой скилл зовётся полным именем** — `av-dev:doc-canon`, `av-dev:task-track`,
|
||||
`av-dev:code-review`. Короткое имя может разрешиться в устаревшую проектную копию
|
||||
из `.claude/skills/`, и подмены не будет видно ни в докладе, ни в поведении.
|
||||
|
||||
**Путь в дерево чужого плагина не пишется никогда.** `$CLAUDE_PLUGIN_ROOT` ведёт
|
||||
только в свой плагин; вычисленный от него путь к соседу либо не откроется, либо
|
||||
откроет чужую установку. Нужен чужой справочник — зови владеющий им скилл, он
|
||||
прочитает его сам.
|
||||
|
||||
**Вызов не разрешился — плагина в проекте нет.** Это исход, а не поломка: назови
|
||||
строкой доклада, чего теперь не делает никто, и продолжай работу. Молчать нельзя,
|
||||
пропуск неотличим от сделанного; выдумывать обходной путь нельзя тоже.
|
||||
|
||||
**Присутствие узнаётся вызовом или следом в проекте, но не объявлением.** Перечня
|
||||
установленных плагинов проект не ведёт — он разошёлся бы с действительностью
|
||||
молча. Что сосед здесь работал, видно по заведённому им файлу: `docs/.docs.json` —
|
||||
канон, `<каталог задач>/.tasks.json` — задачи, `openspec/config.yaml` — конвейер.
|
||||
Имя файла — имя плагина, который его завёл, и держит он в том числе версию своего
|
||||
формата: у канона документов и у каталога задач они свои и двигаются порознь.
|
||||
|
||||
<!-- /копия: граница-плагинов -->
|
||||
|
||||
Здесь это значит: вызов не разрешился — плагина конвейера в проекте нет, и тогда
|
||||
OpenSpec заводит человек командой выше.
|
||||
|
||||
## Чего этот скилл не делает
|
||||
|
||||
- **Не пишет спеки и предложения.** Это `opsx:propose` и конвейер задачи.
|
||||
- **Не ведёт документы канона** — их дом плагин `av-dev-docs`, и адреса в
|
||||
`context` только на них ссылаются.
|
||||
- **Не чинит расхождение формы с версией OpenSpec в проекте.** Оно чинится в
|
||||
плагине: константы скрипта, образец здесь, запись в журнал версий канона.
|
||||
- **Не судит, ссылается `context` на документы или пересказывает их.** Машине
|
||||
этот разрез не виден; его смотрит агент `doc-consistency` из плагина канона.
|
||||
Плагина нет — эту проверку не делает никто, и так и скажи.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Образец `openspec/config.yaml`
|
||||
|
||||
Каталог `openspec/` заводится командой — `openspec init --tools claude`, — и она
|
||||
кладёт `config.yaml` с закомментированным примером внутри. Пример **заменяется
|
||||
целиком**: нетронутый файл выглядит настроенным, а работает как пустой.
|
||||
|
||||
**Это маршрутизатор, а не второй дом фактов.** Сюда пишут ровно то, что нужно
|
||||
**в момент порождения артефакта** и чего в этот момент ещё никто не открыл:
|
||||
язык, правила именования capability, придирки валидатора и **адреса** документов
|
||||
канона. Пересказ паспорта, инвариантов, конвенций и правил ревью сюда не
|
||||
переносится: расходится он молча, а замечают это в уже написанном предложении.
|
||||
|
||||
```yaml
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
Language: Russian
|
||||
Пиши на русском, но:
|
||||
- Структурные заголовки оставляй на английском:
|
||||
## ADDED/MODIFIED/REMOVED Requirements, ### Requirement:, #### Scenario:
|
||||
- Ключевые слова GIVEN/WHEN/THEN и RFC 2119 (SHALL/MUST/SHOULD) — на английском
|
||||
- Технические термины, пути и код — на английском
|
||||
|
||||
Имена capabilities:
|
||||
- Capability — это ПОВЕДЕНИЕ или домен системы, а не пакет кода (совпадение с
|
||||
именем пакета допустимо, но не критерий).
|
||||
- Существительное, понятное без знания кода: ingest, parsing, storage,
|
||||
read-api. НЕ store/httpapi — это реализация.
|
||||
- Гранулярность по принципу «требования меняются вместе». Дробить, когда в
|
||||
одной спеке смешиваются разные заботы. Переименовать дёшево (RENAMED
|
||||
Requirements) — не дроби преждевременно в маленьком проекте.
|
||||
|
||||
RFC 2119 — требование валидатора, не стиль:
|
||||
- Каждое ### Requirement ОБЯЗАНО содержать литерал SHALL или MUST, иначе
|
||||
`openspec validate` падает. Поэтому эти слова и WHEN/THEN не русифицируем.
|
||||
|
||||
Что это за проект — читай перед предложением, а не отсюда:
|
||||
- docs/passport.md — цель, её граница (чем проект НЕ является), потребители,
|
||||
типовые сценарии, референсы;
|
||||
- CLAUDE.md — инварианты с severity и семантика гейта;
|
||||
- docs/architecture.md — устройство; docs/security.md — периметр;
|
||||
docs/adr/ — почему решено так; docs/research/ — что уже измерено.
|
||||
Пересказа этих документов здесь нет намеренно: второй дом факта расходится с
|
||||
первым молча, и заметно это становится в предложении, которое уже написано.
|
||||
|
||||
Ревью: правило выбора метки и состав проходов здесь не пересказываем — их дом
|
||||
скилл av-dev:code-review, проектная настройка — docs/review.md.
|
||||
|
||||
Конвенции кода: механизированное проверяет гейт, прозой остаётся
|
||||
docs/conventions/. Ни состав шагов гейта, ни перечень конвенций здесь не
|
||||
пересказываем: и то и другое растёт по ходу задач.
|
||||
|
||||
Развилка или блокер — сперва prior art. Готовые решения смотрим в референсах
|
||||
паспорта, отвергаем — с названной причиной, и причина идёт в design.md этого
|
||||
же изменения.
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- Capabilities называй по поведению или домену системы, не по пакету кода
|
||||
- "Why и What Changes — языком домена из docs/passport.md: без SHALL, без имён модулей и функций там, где вещь называется по-русски"
|
||||
design:
|
||||
- "Назови рассмотренные варианты и причину отказа от каждого — из них потом пишется ADR"
|
||||
- "Решение объясняется через то, что человек увидит иначе, а не через устройство кода"
|
||||
specs:
|
||||
# Кавычки обязательны: без них YAML обрежет строку на первом '#'.
|
||||
- "Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)"
|
||||
- "Сценарий — ровно #### (четыре решётки); три или список молча теряются"
|
||||
- "SHALL/MUST должно стоять в ПЕРВОМ абзаце требования: валидатор смотрит только его"
|
||||
- "Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском"
|
||||
tasks:
|
||||
- "Критерии приёмки задачи — отдельным блоком и дословно: файл задачи закрытие удалит, критерии обязаны его пережить"
|
||||
- "Рубрика ревью дизайна, если оно её дало, идёт в тот же блок: приёмка судится по одному списку, а не по двум"
|
||||
- "Шаг плана формулируется проверяемо — по нему видно «сделано / не сделано» без суждения"
|
||||
```
|
||||
|
||||
**Четыре правила для `specs` сняты отказами валидатора, а не выведены из
|
||||
документации** — потому и записаны дословно: без них каждое второе предложение
|
||||
узнаёт их падением `openspec validate --strict`.
|
||||
|
||||
**Правила для `proposal` и `design` держат чекпоинт скилла
|
||||
`av-dev:code-resolve`.** Там работа останавливается и человеку объясняют, в
|
||||
чём проблема и как её решают, — а объяснение **собирается из этих двух
|
||||
артефактов**, а не сочиняется заново: третий пересказ одного и того же разошёлся
|
||||
бы с обоими. Требование поэтому стоит здесь, в момент порождения артефакта, а не
|
||||
в скилле, который спохватится позже. `design.md` при этом ещё и **сырьё для
|
||||
ADR** — отвергнутый вариант с названной причиной и есть половина будущей записи.
|
||||
|
||||
**Правила для `tasks` держит тот же скилл, и по той же причине — момент
|
||||
порождения.** `tasks.md` — единственное, что переживает задачу: файл задачи
|
||||
закрытие удаляет, а приёмка потом судится по критериям, которые в него
|
||||
скопированы. Туда же ложится рубрика ревью дизайна, если оно её дало. Записанное
|
||||
в момент порождения не приходится вспоминать шагом позже, когда артефакт уже
|
||||
написан. Блок `context` проект
|
||||
дополняет своим (стек, разведка, особенности домена), но **адреса паспорта и
|
||||
`CLAUDE.md` обязательны** — отсутствие адреса к существующему документу
|
||||
`openspec.py check` называет отказом.
|
||||
|
||||
**Ключи под `rules:` — имена артефактов схемы**, а не свободные слова:
|
||||
`proposal`, `specs`, `design`, `tasks`. Правило под чужим именем не применяется
|
||||
и об этом не сообщает, поэтому `rules.spec` вместо `rules.specs` даёт конфиг,
|
||||
выглядящий написанным и не работающий; `openspec.py check` такой ключ называет.
|
||||
Перечень артефактов задаёт OpenSpec, а не мы, — за его актуальностью следит
|
||||
`openspec.py form`.
|
||||
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Форма `openspec/config.yaml`: проверка проекта и сверка слепка с инструментом.
|
||||
|
||||
Каталог `openspec/` — предпосылка **конвейера**, а не канона документов: без него
|
||||
не работают ни `opsx:propose`, ни ревью дизайна, ни сверка требований. Поэтому и
|
||||
проверка формы живёт здесь, рядом со скиллом, который каталог заводит. Раньше она
|
||||
жила в `docs.py` плагина канона, и у файла было два владельца: один заводит,
|
||||
другой проверяет.
|
||||
|
||||
Проверяется то, что **молчит при поломке**. Файл из коробки хуже отсутствующего:
|
||||
`openspec init` кладёт `config.yaml`, где `context` и `rules` — закомментированный
|
||||
пример на английском. Он есть, он валиден, имя правильное — и читается как
|
||||
настроенный, работая как пустой. Узнаётся это по уже написанному предложению.
|
||||
|
||||
Разбираем текстом, а не YAML-парсером: у скриптов ноль внешних зависимостей, а
|
||||
PyYAML в стандартной библиотеке нет. Всё проверяемое различимо построчно,
|
||||
комментарии отброшены, ключи верхнего уровня стоят в первой колонке.
|
||||
|
||||
Коды выхода — общий словарь скриптов av-dev:
|
||||
0 сошлось
|
||||
1 дрейф: форма разошлась с ожидаемой
|
||||
2 ошибка употребления: аргументы
|
||||
3 окружение: не тот каталог, инструмент не отвечает
|
||||
4 внутренний сбой
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
OK, DRIFT, USAGE, ENV, INTERNAL = 0, 1, 2, 3, 4
|
||||
|
||||
# Команда заведения. Названа поимённо потому, что её печатает отказ, а отказ без
|
||||
# команды заставляет искать её в другом месте.
|
||||
OPENSPEC_INIT = "openspec init --tools claude"
|
||||
|
||||
# Адреса, которые обязан назвать блок context. Не пересказ документов, а именно
|
||||
# ссылки: предложение пишется до того, как кто-либо откроет docs/, и без этих
|
||||
# двух строк его пишут, не зная ни границы домена, ни инвариантов. Список
|
||||
# короткий намеренно — длинный превращает context во второй дом фактов.
|
||||
#
|
||||
# Третий элемент — путь, по которому проверяется, есть ли документ в проекте
|
||||
# вообще. Канон документов ставится отдельным плагином и может быть не подключён;
|
||||
# требовать ссылку на файл, которого нет, значит требовать битую ссылку.
|
||||
OPENSPEC_POINTERS = [
|
||||
("passport", "docs/passport.md",
|
||||
"граница домена и «чем НЕ является» останутся непрочитанными"),
|
||||
("CLAUDE.md", "CLAUDE.md",
|
||||
"инварианты и семантика гейта останутся непрочитанными"),
|
||||
]
|
||||
|
||||
# --- Слепок чужого инструмента ----------------------------------------------
|
||||
#
|
||||
# Схема, перечень артефактов и версия, на которой это проверено, живут в OpenSpec
|
||||
# и меняются без нашего участия; здесь они записаны, чтобы проверка шла без
|
||||
# запуска node на каждом прогоне.
|
||||
#
|
||||
# Слепок стареет, и потому есть кто это замечает: `check` сравнивает major.minor
|
||||
# установленного OpenSpec с OPENSPEC_CHECKED и, если они разошлись, говорит
|
||||
# замечанием «форма не перепроверена». Перепроверяет команда `form` — она
|
||||
# спрашивает сам инструмент и печатает, что разошлось. Патч-версия сравнением
|
||||
# намеренно не берётся: форма конфига в ней не меняется, а замечание на каждый
|
||||
# багфикс приучило бы пролистывать весь блок.
|
||||
OPENSPEC_CHECKED = "1.5"
|
||||
OPENSPEC_SCHEMA = "spec-driven"
|
||||
|
||||
# Артефакты схемы. Ключ `rules:` адресуется артефакту, и адресованный
|
||||
# несуществующему **молча не действует** — ровно тот класс, ради которого вся
|
||||
# проверка и заведена.
|
||||
OPENSPEC_ARTIFACTS = ("proposal", "specs", "design", "tasks")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
errors: list[str] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
skipped: list[str] = field(default_factory=list)
|
||||
|
||||
def error(self, msg: str) -> None:
|
||||
self.errors.append(msg)
|
||||
|
||||
def note(self, msg: str) -> None:
|
||||
self.notes.append(msg)
|
||||
|
||||
def skip(self, msg: str) -> None:
|
||||
self.skipped.append(msg)
|
||||
|
||||
|
||||
def fail(code: int, msg: str) -> NoReturn:
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def openspec_cli(args: list[str]) -> str | None:
|
||||
"""Спросить сам инструмент. None — его нет или он не ответил."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["openspec", *args], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return out.stdout.strip() if out.returncode == 0 else None
|
||||
|
||||
|
||||
def rules_keys(live: str) -> list[str]:
|
||||
"""Имена артефактов, которым адресованы правила, — и только они.
|
||||
|
||||
Идём от строки `rules:` до следующего ключа нулевой колонки, а не ищем
|
||||
отступ по всему файлу: блок `context: |` — литеральный скаляр, внутри него
|
||||
строки вида «Language: Russian» и «av-dev:code-review» выглядят
|
||||
ключами и дали бы находку на ровном месте. Проверено на живом конфиге,
|
||||
который так и падал.
|
||||
"""
|
||||
out: list[str] = []
|
||||
inside = False
|
||||
for line in live.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
if not line[0].isspace():
|
||||
inside = line.startswith("rules:")
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
m = re.fullmatch(r" ([A-Za-z_-]+):\s*", line)
|
||||
if m:
|
||||
out.append(m.group(1))
|
||||
return out
|
||||
|
||||
|
||||
def rules_block(live: str, name: str) -> str:
|
||||
"""Строки правил, адресованных одному артефакту.
|
||||
|
||||
Обход тот же, что у `rules_keys`, и по той же причине: искать по всему файлу
|
||||
нельзя. Литеральный скаляр `context` называет `SHALL` уже в образце, поэтому
|
||||
проверка «правила называют SHALL» грепом по файлу проходила при **пустом**
|
||||
`rules.specs` — то есть молчала ровно в том случае, ради которого написана.
|
||||
"""
|
||||
out: list[str] = []
|
||||
in_rules = False
|
||||
in_name = False
|
||||
for line in live.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
if not line[0].isspace():
|
||||
in_rules = line.startswith("rules:")
|
||||
in_name = False
|
||||
continue
|
||||
if not in_rules:
|
||||
continue
|
||||
m = re.fullmatch(r" ([A-Za-z_-]+):\s*", line)
|
||||
if m:
|
||||
in_name = m.group(1) == name
|
||||
continue
|
||||
if in_name:
|
||||
out.append(line)
|
||||
return "\n".join(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 rules_keys(live):
|
||||
rep.error(
|
||||
"в openspec/config.yaml нет rules.specs — придирки валидатора "
|
||||
"нигде не записаны, и каждое предложение узнаёт их отказом"
|
||||
)
|
||||
elif "SHALL" not in rules_block(live, "specs"):
|
||||
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())
|
||||
Reference in New Issue
Block a user