- «что против как» на пограничных правилах не работает: capability проверяется снаружи работающей системы, конвенция — только в исходном тексте, и отсюда расходятся направление, распространение и шкала - добавлен признак для спорного случая: обязательство перед внешним потребителем — в спеку, зависимость автора следующего патча — в конвенцию
Канон конвенций
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
docs/conventions/, коммитят их и живут дальше самостоятельно — как с
ansible-roles: канон не источник истины во время работы, а лавка, из
которой берут.
Сами конвенции лежат в conventions/, обвязка — в корне:
| Файл | Что описывает |
|---|---|
README.md |
устройство канона, оси, сборка копий, жизненный цикл |
| LANGUAGE.md | язык записи правил: идентификаторы, модальность, обоснование |
| GUIDE.md | как ведут конвенции: когда заводить, механизация, отступления |
| READING.md | как читать конвенцию: то, что едет к потребителю |
| suite.toml | манифест набора: язык, темы, префиксы правил |
conv |
сборка копий |
К потребителю едет содержимое conventions/ и один файл обвязки —
READING.md; остальная обвязка остаётся в каноне. Самодостаточность копии это
не нарушает: конвенция называет язык записи одной
строкой с номером версии и не ссылается на путь (LANGUAGE.md, раздел «Ссылка
на язык из конвенции»).
Правило то же, что у ролей: деплоится и читается только то, что лежит в git репозитория. Канон никем не подключается на лету.
Направление — конвенция → код
Конвенция формулируется независимо от конкретного приложения. Она задаёт правило; код ему следует. Обратное направление запрещено: то, что приложение уже делает иначе, не является аргументом против правила — это отступление, и его место в локальной части копии того репозитория, а не в переформулировке канона.
Отсюда практические следствия:
- в каноне нет утверждений о том, как что-то устроено в конкретном репозитории («у нас так в девяти плейбуках из тридцати трёх») — только нормы и условия их применимости;
- в каноне нет списка, кто на что подписан: подписка — свойство репозитория, а не конвенции;
- расхождение канона с кодом чинится либо кодом, либо честной записью отступления, либо — если правило оказалось неверным — правкой правила по существу, а не подгонкой под факт.
Оси
conventions/
arch/ решения, переживающие смену языка и инструментов
lang/<язык>/ как решение реализуется и механизируется в языке
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
Ось файл объявляет в шапке, а не наследует от директории (META-38):
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, но годится любой идентификатор, пригодный для имени файла — имя попадает и в файловую систему потребителя, и в его манифест. Файл конвенции объявляет тему в шапке:
topic: db-identifiers
prefix: KEYS
Слои одной темы несут одно и то же имя — по нему они и собираются в один документ, как бы ни назывались их файлы. Имя файла повторяет тему из удобства, но истина — в шапке.
Темы перечислены в манифесте набора — suite.toml,
секция [topics.live]: имя и однострочное описание. Имя темы не
переиспользуется по той же причине, что и префикс: оно живёт в чужих
репозиториях — в шапке origin: каждой копии, в подписке манифеста, в тексте
ссылок, — и выданное второй теме начинает указывать на другой набор правил.
Раз имя вечно, называют тему решением и его адресатом, а не ролью части
конкретного проекта (META-37). Логи сервера и логи браузера — это logging и
client-logging, а не logging-backend и logging-frontend: роль
принадлежит сегодняшнему устройству одного репозитория и молча начинает врать,
а суффиксная пара вдобавок навязывает чтение «две разновидности одного», хотя
по границе темы это разные решения — общего у них три правила из сорока.
Префиксы
Каждый файл канона объявляет в шапке свой префикс правил:
prefix: KEYS
Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
манифесте набора, секция [prefixes.live]. Префикс выбирается под файл, а не
выводится по формуле, и не переиспользуется никогда. Правила адресуются
идентификатором KEYS-5 — без пути к файлу. Подробности формы —
LANGUAGE.md.
Буква X в начале префикса зарезервирована за репозиториями: канон её не
занимает никогда, а локальные правила потребителя берут префиксы только на
неё (XTIM, XLOG). Так столкновение локального префикса с будущим
префиксом канона невозможно по построению, и согласовывать заранее ничего не
нужно.
Расширение
Файл в lang/ или stack/ может объявить в шапке ещё и базу:
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: и локальной частью.
Шапка копии ставится при сборке и в каноне не хранится:
---
origin: time
---
В origin: стоит имя темы — то же, что в манифесте набора и в шапках
topic: слоёв, из которых файл собран. Больше в шапке ничего нет: отпечатка
канона и даты синхронизации в ней не хранится, потому что обновление
перезаписывает файл в рабочем дереве, и что именно изменилось, показывает
git diff до коммита. Второй механизм сравнения рядом с git не нужен.
Маркер локальной части — единственная машинно значимая разметка внутри файла:
<!-- 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 остаётся в каноне: три его раздела адресованы
автору набора и ссылаются на правила GUIDE.md, которых у потребителя нет.
Два документа — один словарь, и это единственное место, где возможен дрейф.
Правка ключевых слов или состава частей правила обязана дойти до READING.md
(META-30), а сверить их дёшево: таблицы либо совпадают, либо нет.
Два манифеста
Манифестов в модели два, и они отвечают на разные вопросы:
| Файл | Где лежит | Что описывает |
|---|---|---|
suite.toml |
в наборе | сам набор: язык, темы, префиксы правил |
.conventions.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.
Команды
conv list # какие темы есть в каноне и что подключено
conv add time # добавить тему в манифест и собрать файл
conv add time --for backend # то же, когда компонентов несколько
conv pull # пересобрать всё, что перечислено в манифесте
# (и обновить READING.md рядом с копиями)
conv pull --for web # только один компонент
При одном компоненте --for не нужен. При нескольких команда без него не
угадывает, а отказывает и перечисляет имена.
Отчёт о том, что изменилось, отдельной командой не выдаётся: после pull
его показывает git diff, а решение — принять, поправить или откатить —
принимает человек перед коммитом.
Транспорт обратно в канон не предусмотрен. Улучшение, найденное в репозитории, переносится в канон руками: это редкая операция, и её цена — не аргумент против того, чтобы направление оставалось односторонним.
Запускать из корня репозитория:
~/projects/private/dev-conventions/conv pull
Обёртка в раннере репозитория (inv conventions -- pull для ansible,
task conventions -- pull для Go) — тонкий проброс аргументов, чтобы
логика не размножалась по репозиториям в двух диалектах.
Жизненный цикл
- В канон. Новая конвенция пишется в том репозитории, где заболело, и
переносится в канон, когда стало ясно, что общего в ней больше, чем
местного. Локальная часть при этом не едет: в канон попадает только норма,
а префикс на
Xменяется на канонический — то есть правила получают новые идентификаторы. - Из канона. Устаревшая конвенция удаляется вместе с обходом потребителей — тихо осиротить копии нельзя.
- История. Канон коммитится при каждой правке: только git канона отвечает на вопрос, почему база сформулирована так.
Состояние
Модель выше — согласованная, а не реализованная. conv пока собран под
прежнюю: зеркальное дерево копий вместо плоского, именованные регионы
<!-- local:имя --> вместо одного маркера, origin_hash в шапке и команды
status, diff, push; READING.md рядом с копиями он тоже пока не
кладёт, компонентов и объявленной оси не знает и выбирает слои по пути. Сами
конвенции уже приведены к новой модели — именованных регионов в каноне нет,
ось объявлена в шапках. Ни один репозиторий-потребитель не подключён, поэтому
переход никого не ломает.