- шкала обязательности объявлена инвариантом, а набор ключевых слов — параметром естественного языка набора: для английского готовый словарь даёт BCP 14, для прочих берут перевод стандарта или делают свой; отклонены синонимы ступеней и `SHALL`, занятый OpenSpec - применены шесть дельт: нормативно только заглавное написание (RFC 8174), ДОЛЖЕН требует названного вреда и машинной проверки сразу, ДОПУСКАЕТСЯ адресовано рецензенту, МЕХАНИЗИРОВАНО выведено из шкалы в отметку рядом с модальностью (ISO/IEC/IEEE 29148), у таблиц объявлены политика совпадения и полнота (DMN) - «Форма записи — LANGUAGE.md» в двенадцати конвенциях заменена строкой о версии языка по образцу boilerplate BCP 14: пути канона в копии не существует, а словарь и правило заглавных строка несёт сама
256 lines
16 KiB
Markdown
256 lines
16 KiB
Markdown
# Канон конвенций
|
||
|
||
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
|
||
`docs/conventions/`, коммитят их и живут дальше самостоятельно — как с
|
||
`ansible-roles`: канон не источник истины во время работы, а лавка, из
|
||
которой берут.
|
||
|
||
Сами конвенции лежат в `conventions/`, обвязка — в корне:
|
||
|
||
| Файл | Что описывает |
|
||
|---|---|
|
||
| `README.md` | устройство канона, оси, сборка копий, жизненный цикл |
|
||
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» |
|
||
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
||
| `prefixes.toml` | реестр префиксов правил |
|
||
| `conv` | сборка копий |
|
||
|
||
Обвязка живёт только в каноне и в репозитории не оказывается — в копию едет
|
||
лишь содержимое `conventions/`. Самодостаточность копии это не нарушает:
|
||
конвенция называет язык записи одной строкой с номером версии и не ссылается
|
||
на путь (`LANGUAGE.md`, раздел «Ссылка на язык из конвенции»).
|
||
|
||
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
|
||
git репозитория**. Канон никем не подключается на лету.
|
||
|
||
## Направление — конвенция → код
|
||
|
||
Конвенция формулируется независимо от конкретного приложения. Она задаёт
|
||
правило; код ему следует. Обратное направление запрещено: то, что
|
||
приложение уже делает иначе, **не является аргументом против правила** — это
|
||
отступление, и его место в локальной части копии того репозитория, а не в
|
||
переформулировке канона.
|
||
|
||
Отсюда практические следствия:
|
||
|
||
- в каноне нет утверждений о том, как что-то устроено в конкретном
|
||
репозитории («у нас так в девяти плейбуках из тридцати трёх») — только
|
||
нормы и условия их применимости;
|
||
- в каноне нет списка, кто на что подписан: подписка — свойство
|
||
репозитория, а не конвенции;
|
||
- расхождение канона с кодом чинится либо кодом, либо честной записью
|
||
отступления, либо — если правило оказалось неверным — правкой правила по
|
||
существу, а не подгонкой под факт.
|
||
|
||
## Оси
|
||
|
||
```
|
||
conventions/
|
||
arch/ решения, переживающие смену языка и инструментов
|
||
lang/<язык>/ как решение реализуется и механизируется в языке
|
||
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
||
```
|
||
|
||
Оси — раскладка **канона**; в репозитории копия лежит плоско, файлом на
|
||
тему. Пути файлов даются относительно `conventions/`
|
||
(`arch/db-identifiers.md`) и адресуют исходник канона, а не место в копии.
|
||
На **правила** ссылаются идентификатором без пути: `KEYS-5`. Префикс
|
||
уникален по всему канону (реестр — `prefixes.toml`), поэтому идентификатор
|
||
не зависит ни от оси, ни от того, как собран файл у потребителя.
|
||
|
||
Тест — по тому, замена чего убивает правило:
|
||
|
||
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
|
||
> хранилища или транспорта** → `stack/`. Не умирает ни от того, ни от
|
||
> другого → `arch/`.
|
||
|
||
`PK — ULID, генерирует приложение` не умирает ни от чего — это лежит в
|
||
данных → `arch/`. `internal/ident`, `ident.Parse` на границах умирают со
|
||
сменой языка → `lang/go/`. `enum как TEXT без CHECK` переживёт Go → Python,
|
||
но не переживёт уход от SQLite → это `stack/sqlite/`.
|
||
|
||
Ось определяется **природой правила**, а не тем, сколько сегодня
|
||
потребителей. Конвенция независима от приложений по построению, поэтому
|
||
арх-слой выделяется тогда, когда правило действительно не зависит от языка,
|
||
а не когда появился второй язык.
|
||
|
||
**Известный долг.** По этому тесту `lang/go/errors.md`, `lang/go/logging.md`
|
||
и `lang/go/db-schema.md` содержат невыделенные слои: у первых двух —
|
||
архитектурное ядро (уровень как адресат, что не логируем; трансляция ошибки
|
||
на внешней границе, приватный канал против публичного), у третьего — целый
|
||
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
|
||
работа.
|
||
|
||
## Префиксы
|
||
|
||
Каждый файл канона объявляет в шапке свой префикс правил:
|
||
|
||
```yaml
|
||
prefix: KEYS
|
||
```
|
||
|
||
Четыре заглавные латинские буквы, уникальные по всему канону; реестр —
|
||
[`prefixes.toml`](prefixes.toml). Префикс выбирается под файл, а не выводится
|
||
по формуле, и не переиспользуется никогда. Правила адресуются идентификатором
|
||
`KEYS-5` — без пути к файлу. Подробности формы — `LANGUAGE.md`.
|
||
|
||
Буква `X` в начале префикса зарезервирована за репозиториями: канон её не
|
||
занимает никогда, а локальные правила потребителя берут префиксы только на
|
||
неё (`XTIM`, `XLOG`). Так столкновение локального префикса с будущим
|
||
префиксом канона невозможно по построению, и согласовывать заранее ничего не
|
||
нужно.
|
||
|
||
## Расширение
|
||
|
||
Файл в `lang/` или `stack/` может объявить в шапке ещё и базу:
|
||
|
||
```yaml
|
||
extends: arch/db-identifiers.md
|
||
```
|
||
|
||
Расширение **только реализует и сужает** базу, но не отменяет её. Если
|
||
слою нужно противоречить базе — это сигнал одного из двух: либо у базы
|
||
неверно сформулировано условие применимости (чинится в каноне), либо
|
||
репозиторий на базу просто не подписан.
|
||
|
||
`extends` — документация связи, а не механизм: за тем, чтобы база лежала
|
||
рядом, никто не следит.
|
||
|
||
## Копия в репозитории
|
||
|
||
Копия плоская: **один файл на тему**, слои осей идут внутри него секциями в
|
||
порядке `arch` → язык → стек. Пути канона в копии не воспроизводятся.
|
||
|
||
```
|
||
docs/conventions/
|
||
README.md собственный, не собирается
|
||
time.md arch/time.md + lang/go/time.md
|
||
db-identifiers.md arch/db-identifiers.md + lang/go/db-identifiers.md
|
||
app-directories.md arch/… + stack/ansible/…
|
||
```
|
||
|
||
Так конвенция остаётся самодостаточным документом: тот, кто проверяет по ней
|
||
код — человек или агент, — читает один файл и не собирает тему из трёх мест.
|
||
|
||
**Шапка копии** ставится при сборке и в каноне не хранится:
|
||
|
||
```yaml
|
||
---
|
||
origin: time
|
||
---
|
||
```
|
||
|
||
Больше в шапке ничего нет. Отпечатка канона и даты синхронизации в ней не
|
||
хранится: обновление перезаписывает файл в рабочем дереве, и что именно
|
||
изменилось, показывает `git diff` до коммита. Второй механизм сравнения
|
||
рядом с git не нужен.
|
||
|
||
**Маркер локальной части** — единственная машинно значимая разметка внутри
|
||
файла:
|
||
|
||
```markdown
|
||
<!-- conv:local -->
|
||
|
||
MIGR-2, MIGR-4 механизированы — `internal/archrules`.
|
||
MIGR-6 не соблюдается в `show_history`, `queue`: составные ключи там
|
||
появились до конвенции, миграция данных не окупается.
|
||
```
|
||
|
||
Всё ниже маркера принадлежит репозиторию и переживает обновление; всё выше —
|
||
пересобирается из канона. Маркер один и безымянный, поэтому у него нет
|
||
имени, которое можно осиротить переименованием.
|
||
|
||
Ниже маркера живёт то, чего канон о репозитории не знает: механизация,
|
||
отступления, разрешение условий («Здесь: INTEGER PK, id наружу не выходят»),
|
||
ссылки на ADR и код, а также **собственные правила** — с префиксом на `X`,
|
||
по тем же правилам формы, что и канон.
|
||
|
||
Если местных правок стало больше, чем каноничного текста, копия перестаёт
|
||
быть копией: `origin:` из шапки убирают, и дальше это обычный документ
|
||
репозитория. Файл, оставивший шапку, при следующем обновлении потеряет
|
||
всё, что выше маркера.
|
||
|
||
## Манифест
|
||
|
||
Откуда взяты копии и где брать обновления — `.conventions.toml` в корне
|
||
репозитория-потребителя:
|
||
|
||
```toml
|
||
source = "ssh://git@git.vakhrushev.me:2222/av/dev-conventions.git"
|
||
|
||
lang = ["go"]
|
||
stack = ["sqlite", "htmx"]
|
||
|
||
topics = ["time", "config", "db-identifiers"]
|
||
```
|
||
|
||
`lang` и `stack` выбирают строку разреженной матрицы: файл темы собирает
|
||
только те слои, которые репозиторию подходят. `topics` — подписка; списка
|
||
подписчиков у канона по-прежнему нет, список тем есть только у потребителя.
|
||
|
||
Как именно инструмент добирается до канона — путь на диске, git, HTTP —
|
||
дело инструмента, а не модели. Манифест отвечает на два вопроса: откуда это
|
||
взято и где искать обновления.
|
||
|
||
Отдельного лок-файла нет. Он отвечал бы на «что было в прошлый раз», а на
|
||
это отвечает git: копии закоммичены, автоматического обновления не
|
||
существует, и любое изменение проходит через чтение диффа человеком.
|
||
|
||
## Контракт с агентом
|
||
|
||
Копии — обычные файлы, и правка их агентом никак не отличима от правки
|
||
любого другого документа. Это главный канал тихого дрейфа, поэтому
|
||
`AGENTS.md` каждого потребителя должен явно говорить:
|
||
|
||
> Файлы в `docs/conventions/` с шапкой `origin:` — копии из канона
|
||
> `dev-conventions`. Репозиторное пишется только ниже `<!-- conv:local -->`;
|
||
> всё выше маркера перезаписывается при обновлении. Своё правило — с
|
||
> префиксом на `X`.
|
||
|
||
## Команды
|
||
|
||
```bash
|
||
conv list # какие темы есть в каноне
|
||
conv add time # добавить тему в манифест и собрать файл
|
||
conv pull # пересобрать всё, что перечислено в манифесте
|
||
```
|
||
|
||
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
|
||
его показывает `git diff`, а решение — принять, поправить или откатить —
|
||
принимает человек перед коммитом.
|
||
|
||
Транспорт обратно в канон не предусмотрен. Улучшение, найденное в
|
||
репозитории, переносится в канон руками: это редкая операция, и её цена —
|
||
не аргумент против того, чтобы направление оставалось односторонним.
|
||
|
||
Запускать из корня репозитория:
|
||
|
||
```bash
|
||
~/projects/private/dev-conventions/conv pull
|
||
```
|
||
|
||
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
|
||
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
|
||
логика не размножалась по репозиториям в двух диалектах.
|
||
|
||
## Жизненный цикл
|
||
|
||
- **В канон.** Новая конвенция пишется в том репозитории, где заболело, и
|
||
переносится в канон, когда стало ясно, что общего в ней больше, чем
|
||
местного. Локальная часть при этом не едет: в канон попадает только норма,
|
||
а префикс на `X` меняется на канонический — то есть правила получают новые
|
||
идентификаторы.
|
||
- **Из канона.** Устаревшая конвенция удаляется вместе с обходом
|
||
потребителей — тихо осиротить копии нельзя.
|
||
- **История.** Канон коммитится при каждой правке: только git канона
|
||
отвечает на вопрос, почему база сформулирована так.
|
||
|
||
## Состояние
|
||
|
||
Модель выше — согласованная, а не реализованная. `conv` пока собран под
|
||
прежнюю: зеркальное дерево копий вместо плоского, именованные регионы
|
||
`<!-- local:имя -->` вместо одного маркера, `origin_hash` в шапке и команды
|
||
`status`, `diff`, `push`. Двенадцать файлов канона всё ещё несут 31 пустой
|
||
именованный регион. Ни один репозиторий-потребитель не подключён, поэтому
|
||
переход никого не ломает.
|