- добавлен ключ governance: без него конвенция, потерявшая topic, была неотличима от GUIDE.md и тихо теряла проверки об отъезде к потребителю - комментарии из манифеста убраны — их всё равно съела бы первая же команда; то, чего не было в README.md, дописано туда
436 lines
31 KiB
Markdown
436 lines
31 KiB
Markdown
# Канон конвенций
|
||
|
||
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
|
||
`docs/conventions/`, коммитят их и живут дальше самостоятельно — как с
|
||
`ansible-roles`: канон не источник истины во время работы, а лавка, из
|
||
которой берут.
|
||
|
||
Сами конвенции лежат в `conventions/`, обвязка — в корне:
|
||
|
||
| Файл | Что описывает |
|
||
|---|---|
|
||
| `README.md` | устройство канона, оси, сборка копий, жизненный цикл |
|
||
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
|
||
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
||
| [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
|
||
| `.conventions-suite.toml` | манифест набора: язык, темы, префиксы правил |
|
||
|
||
К потребителю едет содержимое `conventions/` и один файл обвязки —
|
||
`READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
|
||
не нарушает: конвенция называет язык записи одной
|
||
строкой с номером версии и не ссылается на путь (`LANGUAGE.md`, раздел «Ссылка
|
||
на язык из конвенции»).
|
||
|
||
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
|
||
git репозитория**. Канон никем не подключается на лету.
|
||
|
||
## Направление — конвенция → код
|
||
|
||
Конвенция формулируется независимо от конкретного приложения. Она задаёт
|
||
правило; код ему следует. Обратное направление запрещено: то, что
|
||
приложение уже делает иначе, **не является аргументом против правила** — это
|
||
отступление, и его место в локальной части копии того репозитория, а не в
|
||
переформулировке канона.
|
||
|
||
Отсюда практические следствия:
|
||
|
||
- в каноне нет утверждений о том, как что-то устроено в конкретном
|
||
репозитории («у нас так в девяти плейбуках из тридцати трёх») — только
|
||
нормы и условия их применимости;
|
||
- в каноне нет списка, кто на что подписан: подписка — свойство
|
||
репозитория, а не конвенции;
|
||
- расхождение канона с кодом чинится либо кодом, либо честной записью
|
||
отступления, либо — если правило оказалось неверным — правкой правила по
|
||
существу, а не подгонкой под факт.
|
||
|
||
## Оси
|
||
|
||
```
|
||
conventions/
|
||
arch/ решения, переживающие смену языка и инструментов
|
||
lang/<язык>/ как решение реализуется и механизируется в языке
|
||
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
||
```
|
||
|
||
Ось файл **объявляет в шапке**, а не наследует от директории (META-38):
|
||
|
||
```yaml
|
||
topic: logging
|
||
prefix: SLOG
|
||
lang: go
|
||
```
|
||
|
||
Ключей оси нет — базовый слой темы. Слова те же, что в подписке потребителя
|
||
(`lang`, `stack`), так что переводить между двумя сторонами нечего. Дерево
|
||
директорий повторяет объявленное для человека и остаётся раскладкой
|
||
**канона**: в репозитории копия лежит плоско, файлом на тему. Пути файлов
|
||
даются относительно `conventions/` (`arch/db-identifiers.md`) и адресуют
|
||
исходник канона, а не место в копии. На **правила** ссылаются идентификатором
|
||
без пути: `KEYS-5`. Префикс уникален по всему канону (он перечислен в
|
||
манифесте набора), поэтому идентификатор не зависит ни от оси, ни от того, как
|
||
собран файл у потребителя.
|
||
|
||
Объявление вдобавок выражает то, чего дерево не умеет: слой, осмысленный
|
||
только при совпадении языка и инструмента сразу (`lang: go` и `stack: slog` в
|
||
одной шапке).
|
||
|
||
Тест — по тому, замена чего убивает правило:
|
||
|
||
> Умирает при смене **языка** → `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/` (типы колонок). Это не принцип, а незавершённая
|
||
работа.
|
||
|
||
## Плоский набор
|
||
|
||
Осей может не быть вовсе. Набор, где у каждой темы ровно один слой, — не
|
||
особый режим, а низкий конец той же модели: сборка «база → язык → стек»
|
||
на нём даёт просто копию файла.
|
||
|
||
```
|
||
conventions/
|
||
logging.md topic: logging, prefix: LOGS
|
||
errors.md topic: errors, prefix: ERRS
|
||
time.md topic: time, prefix: TIME
|
||
```
|
||
|
||
Ключей оси в шапках нет, `lang` и `stack` в подписке не пишутся — выбирать
|
||
не из чего. Так дешевле начинать и так выглядит чужой набор, которому три оси
|
||
объяснять незачем, чтобы записать пять правил.
|
||
|
||
Цена платится при росте, и она не в инструменте: когда плоская тема
|
||
расслаивается, уехавшие в новый файл правила получают новый префикс и новую
|
||
нумерацию. Смягчается это тем, что уехавшее правило остаётся на месте
|
||
заглушкой СНЯТО с новым адресом в причине (META-31, META-32), и тем, что
|
||
резать нужно правильной стороной: база остаётся в исходном файле со своими
|
||
идентификаторами, а наружу уезжает специфичное. Если второй язык виден
|
||
заранее, дешевле сразу разложить по осям.
|
||
|
||
## Темы
|
||
|
||
**Тема — набор правил об одном фокусе разработки:** время, конфигурация,
|
||
схема БД. Она же единица подписки и единица сборки: потребитель берёт тему
|
||
целиком, а сборщик складывает в один файл все её слои.
|
||
|
||
Имя темы записывается латиницей; рекомендуется нижний kebab-case, но годится
|
||
любой идентификатор, пригодный для имени файла — имя попадает и в файловую
|
||
систему потребителя, и в его манифест. Файл конвенции объявляет тему в шапке:
|
||
|
||
```yaml
|
||
topic: db-identifiers
|
||
prefix: KEYS
|
||
```
|
||
|
||
Слои одной темы несут одно и то же имя — по нему они и собираются в один
|
||
документ, как бы ни назывались их файлы. Имя файла повторяет тему из
|
||
удобства, но истина — в шапке.
|
||
|
||
Темы перечислены в манифесте набора — `.conventions-suite.toml`,
|
||
секция `[topics.live]`: имя и однострочное описание. Имя темы не
|
||
переиспользуется по той же причине, что и префикс: оно живёт в чужих
|
||
репозиториях — в шапке `origin:` каждой копии, в подписке манифеста, в тексте
|
||
ссылок, — и выданное второй теме начинает указывать на другой набор правил.
|
||
|
||
Раз имя вечно, называют тему **решением и его адресатом**, а не ролью части
|
||
конкретного проекта (META-37). Логи сервера и логи браузера — это `logging` и
|
||
`client-logging`, а не `logging-backend` и `logging-frontend`: роль
|
||
принадлежит сегодняшнему устройству одного репозитория и молча начинает врать,
|
||
а суффиксная пара вдобавок навязывает чтение «две разновидности одного», хотя
|
||
по границе темы это разные решения — общего у них три правила из сорока.
|
||
|
||
## Префиксы
|
||
|
||
Каждый файл канона объявляет в шапке свой префикс правил:
|
||
|
||
```yaml
|
||
prefix: KEYS
|
||
```
|
||
|
||
Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
|
||
манифесте набора, секция `[prefixes.live]`, путём **от корня репозитория**, а
|
||
не от `conventions/` — манифест покрывает и обвязку тоже. Префикс выбирается
|
||
под файл, а не выводится по формуле, и не переиспользуется никогда. Правила
|
||
адресуются идентификатором `KEYS-5` — без пути к файлу. Подробности формы —
|
||
`LANGUAGE.md`.
|
||
|
||
`GUIDE.md` тоже несёт префикс и тоже проверяется как конвенция: правила в нём
|
||
записаны тем же языком и цитируются по номерам. Темы у него нет — подписаться
|
||
на него нельзя, к потребителю он не едет, — и манифест называет его отдельным
|
||
ключом `governance`, чтобы конвенция, потерявшая `topic`, не сошла за него.
|
||
|
||
Буква `X` в начале префикса зарезервирована за репозиториями: канон её не
|
||
занимает никогда, а локальные правила потребителя берут префиксы только на
|
||
неё (`XTIM`, `XLOG`). Так столкновение локального префикса с будущим
|
||
префиксом канона невозможно по построению, и согласовывать заранее ничего не
|
||
нужно.
|
||
|
||
## Расширение
|
||
|
||
Файл в `lang/` или `stack/` может объявить в шапке ещё и базу:
|
||
|
||
```yaml
|
||
extends: arch/db-identifiers.md
|
||
```
|
||
|
||
Расширение **только реализует и сужает** базу, но не отменяет её. Если
|
||
слою нужно противоречить базе — это сигнал одного из двух: либо у базы
|
||
неверно сформулировано условие применимости (чинится в каноне), либо
|
||
репозиторий на базу просто не подписан.
|
||
|
||
`extends` — документация связи, а не механизм: за тем, чтобы база лежала
|
||
рядом, никто не следит. С объявленной осью база к тому же находится сама —
|
||
это слой той же темы без ключей `lang` и `stack`, — так что ключ остаётся
|
||
подсказкой человеку и ничего не выбирает.
|
||
|
||
## Компонент — адресат сборки
|
||
|
||
Подписка принадлежит репозиторию, а собранный документ адресован не
|
||
репозиторию, а куску кода. Пока проект однороден, разницы нет; два языка её
|
||
проявляют: `lang = ["go", "javascript"]` склеили бы в один файл го-слой и
|
||
js-слой, из которых к правимому коду относится ровно половина.
|
||
|
||
**Компонент — область репозитория, где все выбранные слои действуют
|
||
одновременно.** `sqlite` и `postgres` в теме схемы действуют вместе — разные
|
||
таблицы одного сервиса; го-слой и js-слой не действуют вместе никогда, потому
|
||
что строка кода написана на чём-то одном. Компонент поэтому совпадает с тем,
|
||
у чего один язык, один набор инструментов и один вид приложения (META-36).
|
||
|
||
Уровней в модели становится три: набор → проект → компонент. Сборка не
|
||
меняется — та же линейка «база → язык → стек», прогнанная по разу на
|
||
компонент.
|
||
|
||
## Копия в репозитории
|
||
|
||
Копия плоская: **один файл на тему**, слои осей идут внутри него секциями в
|
||
порядке база → язык → стек. Пути канона в копии не воспроизводятся. Каждый
|
||
компонент получает свою директорию:
|
||
|
||
```
|
||
.conventions.toml
|
||
backend/docs/conventions/
|
||
README.md собственный, не собирается
|
||
READING.md как читать конвенцию — приезжает из канона
|
||
logging.md база + lang/go + stack/slog
|
||
time.md arch/time.md + lang/go/time.md
|
||
web/docs/conventions/
|
||
READING.md
|
||
client-logging.md база + lang/javascript + stack/express
|
||
```
|
||
|
||
Так конвенция остаётся самодостаточным документом: тот, кто проверяет по ней
|
||
код — человек или агент, — читает один файл и не собирает тему из трёх мест.
|
||
При одном компоненте это ровно прежняя раскладка — `docs/conventions/` в
|
||
корне.
|
||
|
||
Директории компонентов различны, и это единственное, что разводит копии:
|
||
`logging.md` двух компонентов — разные файлы с одинаковым `origin: logging`,
|
||
и какой из них какой, сборщик знает по манифесту, а читатель — по пути.
|
||
Локальные части у них независимы, ради чего всё и затевается: правило,
|
||
механизированное линтером в go-компоненте, в js-компоненте не механизировано,
|
||
и один общий файл этого не записал бы.
|
||
|
||
`READING.md` лежит рядом с копиями, то есть по одному на компонент. Файл
|
||
генерируемый, а директория с правилами обязана объяснять себя тому, кто в неё
|
||
попал.
|
||
|
||
Имена в директории делятся на три вида: `README.md` принадлежит репозиторию и
|
||
сборщик его не трогает, `READING.md` принадлежит канону и перезаписывается
|
||
целиком, остальные файлы — копии тем с шапкой `origin:` и локальной частью.
|
||
|
||
**Шапка копии** ставится при сборке и в каноне не хранится:
|
||
|
||
```yaml
|
||
---
|
||
origin: time
|
||
---
|
||
```
|
||
|
||
В `origin:` стоит имя темы — то же, что в манифесте набора и в шапках
|
||
`topic:` слоёв, из которых файл собран. Больше в шапке ничего нет: отпечатка
|
||
канона и даты синхронизации в ней не хранится, потому что обновление
|
||
перезаписывает файл в рабочем дереве, и что именно изменилось, показывает
|
||
`git diff` до коммита. Второй механизм сравнения рядом с git не нужен.
|
||
|
||
**Маркер локальной части** — единственная машинно значимая разметка внутри
|
||
файла:
|
||
|
||
```markdown
|
||
<!-- conv:local -->
|
||
|
||
MIGR-2, MIGR-4 — МЕХАНИЗИРОВАНО: `internal/archrules`.
|
||
MIGR-6 не соблюдается в `show_history`, `queue`: составные ключи там
|
||
появились до конвенции, миграция данных не окупается.
|
||
```
|
||
|
||
Всё ниже маркера принадлежит репозиторию и переживает обновление; всё выше —
|
||
пересобирается из канона. Маркер один и безымянный, поэтому у него нет
|
||
имени, которое можно осиротить переименованием.
|
||
|
||
Ниже маркера живёт то, чего канон о репозитории не знает: механизация,
|
||
отступления, разрешение условий («Здесь: INTEGER PK, id наружу не выходят»),
|
||
ссылки на ADR и код, а также **собственные правила** — с префиксом на `X`,
|
||
по тем же правилам формы, что и канон.
|
||
|
||
Если местных правок стало больше, чем каноничного текста, копия перестаёт
|
||
быть копией: `origin:` из шапки убирают, и дальше это обычный документ
|
||
репозитория. Файл, оставивший шапку, при следующем обновлении потеряет
|
||
всё, что выше маркера.
|
||
|
||
## Язык записи едет вместе с копиями
|
||
|
||
Конвенция называет язык одной строкой с номером версии и без пути — строка
|
||
работает и сама по себе. Но семантика заглавных слов живёт в описании языка, а
|
||
описание в репозиторий-потребитель раньше не попадало: агент, читающий копию,
|
||
принимал ДОПУСКАЕТСЯ за бытовое «можно» и терял ровно то, ради чего слово
|
||
введено.
|
||
|
||
Поэтому в `docs/conventions/` сборщик кладёт `READING.md` — короткое описание
|
||
для читателя правил: словарь со значениями, правило заглавных, из чего состоит
|
||
правило и где его граница, как ссылаться, что живёт ниже маркера. Полное
|
||
[LANGUAGE.md](LANGUAGE.md) остаётся в каноне: три его раздела адресованы
|
||
автору набора и ссылаются на правила `GUIDE.md`, которых у потребителя нет.
|
||
|
||
Два документа — один словарь, и это единственное место, где возможен дрейф.
|
||
Правка ключевых слов или состава частей правила обязана дойти до `READING.md`
|
||
(META-30), а сверить их дёшево: таблицы либо совпадают, либо нет.
|
||
|
||
## Два манифеста
|
||
|
||
Манифестов в модели два, и они отвечают на разные вопросы:
|
||
|
||
| Файл | Где лежит | Что описывает |
|
||
|---|---|---|
|
||
| `.conventions-suite.toml` | в наборе | сам набор: язык, темы, префиксы правил |
|
||
| `.conventions.toml` | в проекте | подключение: откуда копии, компоненты и их подписки |
|
||
|
||
Манифест набора — единственное место, где перечислены оба идентификатора
|
||
канона; правила у них общие, поэтому и файл один. Манифест подключения
|
||
отвечает, откуда взяты копии и где брать обновления:
|
||
|
||
```toml
|
||
source = "ssh://git@git.vakhrushev.me:2222/av/dev-conventions.git"
|
||
|
||
[components.backend]
|
||
dir = "backend/docs/conventions"
|
||
lang = ["go"]
|
||
stack = ["slog", "sqlite"]
|
||
topics = ["logging", "errors", "time"]
|
||
|
||
[components.web]
|
||
dir = "web/docs/conventions"
|
||
lang = ["javascript"]
|
||
stack = ["express"]
|
||
topics = ["client-logging"]
|
||
```
|
||
|
||
`lang` и `stack` выбирают строку разреженной матрицы: файл темы собирает
|
||
только те слои, которые компоненту подходят, и совпадают со словами, которыми
|
||
слой объявил свою ось. `topics` — подписка, именами из манифеста набора;
|
||
списка подписчиков у канона по-прежнему нет, список подписок есть только у
|
||
потребителя.
|
||
|
||
Компонент пишется всегда, даже когда он один: сокращённая плоская форма
|
||
сэкономила бы три строки и завела бы второй способ сказать то же самое.
|
||
Имя компонента при этом не служебное — им сборщик отвечает, что и куда
|
||
собрал. У плоского набора `lang` и `stack` в компоненте просто отсутствуют.
|
||
|
||
Как именно инструмент добирается до канона — путь на диске, git, HTTP —
|
||
дело инструмента, а не модели. Манифест отвечает на два вопроса: откуда это
|
||
взято и где искать обновления.
|
||
|
||
Отдельного лок-файла нет. Он отвечал бы на «что было в прошлый раз», а на
|
||
это отвечает git: копии закоммичены, автоматического обновления не
|
||
существует, и любое изменение проходит через чтение диффа человеком.
|
||
|
||
## Контракт с агентом
|
||
|
||
Копии — обычные файлы, и правка их агентом никак не отличима от правки
|
||
любого другого документа. Это главный канал тихого дрейфа, поэтому
|
||
`AGENTS.md` каждого потребителя должен явно говорить:
|
||
|
||
> Файлы с шапкой `origin:` в директориях конвенций (пути — в
|
||
> `.conventions.toml`) — копии из канона `dev-conventions`. Репозиторное
|
||
> пишется только ниже `<!-- conv:local -->`; всё выше маркера
|
||
> перезаписывается при обновлении. Своё правило — с префиксом на `X`.
|
||
|
||
## Команды
|
||
|
||
Копии собирает `convy` — отдельный инструмент, живущий в своём репозитории и
|
||
ставящийся бинарём. Запускают его из корня репозитория-потребителя:
|
||
|
||
```bash
|
||
convy init --source <ссылка на канон> --component backend \
|
||
--dir docs/conventions --lang go
|
||
convy add time # подписаться на тему и собрать файл
|
||
convy add time --for backend # то же, когда компонентов несколько
|
||
convy pull # пересобрать всё, что перечислено в манифесте
|
||
# (и обновить READING.md рядом с копиями)
|
||
convy pull --for web # только один компонент
|
||
convy sync # подвести раскладку файлов под манифест
|
||
convy list # что подключено и что ещё есть в каноне
|
||
convy check # проверить форму того, что лежит здесь
|
||
```
|
||
|
||
При одном компоненте `--for` не нужен. При нескольких команда без него не
|
||
угадывает, а отказывает и перечисляет имена.
|
||
|
||
Манифест подключения правится и руками — это данные, а не текст с
|
||
комментариями. Что бы в нём ни поменяли, раскладку под него подводит `convy
|
||
sync`: чего не хватает — соберёт, что осиротело — уберёт, а копию с локальной
|
||
частью не тронет и назовёт.
|
||
|
||
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
|
||
его показывает `git diff`, а решение — принять, поправить или откатить —
|
||
принимает человек перед коммитом.
|
||
|
||
Транспорт обратно в канон не предусмотрен. Улучшение, найденное в
|
||
репозитории, переносится в канон руками: это редкая операция, и её цена —
|
||
не аргумент против того, чтобы направление оставалось односторонним.
|
||
|
||
Сам канон ведут те же командой под `suite`: `convy suite add` заводит
|
||
конвенцию, `convy suite rule` дописывает правило, `convy suite retire`
|
||
снимает, `convy suite check` проверяет целостность набора.
|
||
|
||
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
|
||
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
|
||
логика не размножалась по репозиториям в двух диалектах.
|
||
|
||
## Жизненный цикл
|
||
|
||
- **В канон.** Новая конвенция пишется в том репозитории, где заболело, и
|
||
переносится в канон, когда стало ясно, что общего в ней больше, чем
|
||
местного. Локальная часть при этом не едет: в канон попадает только норма,
|
||
а префикс на `X` меняется на канонический — то есть правила получают новые
|
||
идентификаторы.
|
||
- **Из канона.** Устаревшая конвенция удаляется вместе с обходом
|
||
потребителей — тихо осиротить копии нельзя.
|
||
- **История.** Канон коммитится при каждой правке: только git канона
|
||
отвечает на вопрос, почему база сформулирована так.
|
||
|
||
## Состояние
|
||
|
||
Модель выше реализована в `convy`: сборка копий, отбор слоёв по объявленной
|
||
оси, маркер локальной части, `READING.md` рядом с копиями, проверка
|
||
целостности набора. Прежний питоновский `conv` — с зеркальным деревом,
|
||
именованными регионами и `origin_hash` — удалён вместе со своей моделью.
|
||
|
||
Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:` в
|
||
природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт
|
||
в чужом репозитории через полгода после первой сборки.
|