# Канон конвенций Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой `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 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`. Репозиторное > пишется только ниже ``; всё выше маркера > перезаписывается при обновлении. Своё правило — с префиксом на `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:` в природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт в чужом репозитории через полгода после первой сборки.