Files
dev-conventions/README.md
T
av c1cb240540 темы: определение, объявление в шапке и манифест набора
- тема — набор правил об одном фокусе разработки, имя латиницей (нижний
  kebab-case рекомендуется, годится любой идентификатор, пригодный для имени
  файла); определение в LANGUAGE.md и README.md
- заведены META-28 и META-29: тема объявляется в шапке (`topic:`), стоит в
  манифесте набора и не переиспользуется; `topic:` добавлен во все 12 файлов
- prefixes.toml и topics.toml слиты в manifest.toml — манифест набора против
  манифеста подключения `.conventions.toml`, разделы topics/prefixes с live
  и retired
2026-07-26 15:27:55 +03:00

294 lines
19 KiB
Markdown
Raw 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) | как ведут конвенции: когда заводить, механизация, отступления |
| [manifest.toml](manifest.toml) | манифест набора: темы и префиксы правил |
| `conv` | сборка копий |
Обвязка живёт только в каноне и в репозитории не оказывается — в копию едет
лишь содержимое `conventions/`. Самодостаточность копии это не нарушает:
конвенция называет язык записи одной строкой с номером версии и не ссылается
на путь (`LANGUAGE.md`, раздел «Ссылка на язык из конвенции»).
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
git репозитория**. Канон никем не подключается на лету.
## Направление — конвенция → код
Конвенция формулируется независимо от конкретного приложения. Она задаёт
правило; код ему следует. Обратное направление запрещено: то, что
приложение уже делает иначе, **не является аргументом против правила** — это
отступление, и его место в локальной части копии того репозитория, а не в
переформулировке канона.
Отсюда практические следствия:
- в каноне нет утверждений о том, как что-то устроено в конкретном
репозитории («у нас так в девяти плейбуках из тридцати трёх») — только
нормы и условия их применимости;
- в каноне нет списка, кто на что подписан: подписка — свойство
репозитория, а не конвенции;
- расхождение канона с кодом чинится либо кодом, либо честной записью
отступления, либо — если правило оказалось неверным — правкой правила по
существу, а не подгонкой под факт.
## Оси
```
conventions/
arch/ решения, переживающие смену языка и инструментов
lang/<язык>/ как решение реализуется и механизируется в языке
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
```
Оси — раскладка **канона**; в репозитории копия лежит плоско, файлом на
тему. Пути файлов даются относительно `conventions/`
(`arch/db-identifiers.md`) и адресуют исходник канона, а не место в копии.
На **правила** ссылаются идентификатором без пути: `KEYS-5`. Префикс
уникален по всему канону (он перечислен в манифесте набора), поэтому
идентификатор не зависит ни от оси, ни от того, как собран файл у
потребителя.
Тест — по тому, замена чего убивает правило:
> Умирает при смене **языка** → `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/` (типы колонок). Это не принцип, а незавершённая
работа.
## Темы
**Тема — набор правил об одном фокусе разработки:** время, конфигурация,
схема БД. Она же единица подписки и единица сборки: потребитель берёт тему
целиком, а сборщик складывает в один файл все её слои.
Имя темы записывается латиницей; рекомендуется нижний kebab-case, но годится
любой идентификатор, пригодный для имени файла — имя попадает и в файловую
систему потребителя, и в его манифест. Файл конвенции объявляет тему в шапке:
```yaml
topic: db-identifiers
prefix: KEYS
```
Слои одной темы несут одно и то же имя — по нему они и собираются в один
документ, как бы ни назывались их файлы. Имя файла повторяет тему из
удобства, но истина — в шапке.
Темы перечислены в манифесте набора — [`manifest.toml`](manifest.toml),
секция `[topics.live]`: имя и однострочное описание. Имя темы не
переиспользуется по той же причине, что и префикс: оно живёт в чужих
репозиториях — в шапке `origin:` каждой копии, в подписке манифеста, в тексте
ссылок, — и выданное второй теме начинает указывать на другой набор правил.
## Префиксы
Каждый файл канона объявляет в шапке свой префикс правил:
```yaml
prefix: KEYS
```
Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
манифесте набора, секция `[prefixes.live]`. Префикс выбирается под файл, а не
выводится по формуле, и не переиспользуется никогда. Правила адресуются
идентификатором `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
---
```
В `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:` из шапки убирают, и дальше это обычный документ
репозитория. Файл, оставивший шапку, при следующем обновлении потеряет
всё, что выше маркера.
## Два манифеста
Манифестов в модели два, и они отвечают на разные вопросы:
| Файл | Где лежит | Что описывает |
|---|---|---|
| `manifest.toml` | в наборе | сам набор: темы и префиксы правил |
| `.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`. Сами конвенции уже приведены к новой модели —
именованных регионов в каноне нет. Ни один репозиторий-потребитель не
подключён, поэтому переход никого не ломает.