Files
av 271603122d манифест приведён к машинному виду, объявлен governance
- добавлен ключ governance: без него конвенция, потерявшая topic, была
  неотличима от GUIDE.md и тихо теряла проверки об отъезде к потребителю
- комментарии из манифеста убраны — их всё равно съела бы первая же
  команда; то, чего не было в README.md, дописано туда
2026-07-28 10:16:39 +03:00

436 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Канон конвенций
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
`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:` в
природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт
в чужом репозитории через полгода после первой сборки.