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

Канон конвенций

Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой docs/conventions/, коммитят их и живут дальше самостоятельно — как с ansible-roles: канон не источник истины во время работы, а лавка, из которой берут.

Сами конвенции лежат в conventions/, обвязка — в корне:

Файл Что описывает
README.md устройство канона, оси, сборка копий, жизненный цикл
LANGUAGE.md язык записи правил: идентификаторы, модальность, обоснование
GUIDE.md как ведут конвенции: когда заводить, механизация, отступления
READING.md как читать конвенцию: то, что едет к потребителю
.conventions-suite.toml манифест набора: язык, темы, префиксы правил

К потребителю едет содержимое 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

Слои одной темы несут одно и то же имя — по нему они и собираются в один документ, как бы ни назывались их файлы. Имя файла повторяет тему из удобства, но истина — в шапке.

Темы перечислены в манифесте набора — .conventions-suite.toml, секция [topics.live]: имя и однострочное описание. Имя темы не переиспользуется по той же причине, что и префикс: оно живёт в чужих репозиториях — в шапке origin: каждой копии, в подписке манифеста, в тексте ссылок, — и выданное второй теме начинает указывать на другой набор правил.

Раз имя вечно, называют тему решением и его адресатом, а не ролью части конкретного проекта (META-37). Логи сервера и логи браузера — это logging и client-logging, а не logging-backend и logging-frontend: роль принадлежит сегодняшнему устройству одного репозитория и молча начинает врать, а суффиксная пара вдобавок навязывает чтение «две разновидности одного», хотя по границе темы это разные решения — общего у них три правила из сорока.

Префиксы

Каждый файл канона объявляет в шапке свой префикс правил:

prefix: KEYS

Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в манифесте набора, секция [prefixes.live], путём от корня репозитория, а не от conventions/ — манифест покрывает и обвязку тоже. Префикс выбирается под файл, а не выводится по формуле, и не переиспользуется никогда. Правила адресуются идентификатором KEYS-5 — без пути к файлу. Подробности формы — LANGUAGE.md.

GUIDE.md тоже несёт префикс и тоже проверяется как конвенция: правила в нём записаны тем же языком и цитируются по номерам. Темы у него нет — подписаться на него нельзя, к потребителю он не едет, — и манифест называет его отдельным ключом governance, чтобы конвенция, потерявшая topic, не сошла за него.

Буква 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), а сверить их дёшево: таблицы либо совпадают, либо нет.

Два манифеста

Манифестов в модели два, и они отвечают на разные вопросы:

Файл Где лежит Что описывает
.conventions-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.

Команды

Копии собирает convy — отдельный инструмент, живущий в своём репозитории и ставящийся бинарём. Запускают его из корня репозитория-потребителя:

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: в природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт в чужом репозитории через полгода после первой сборки.

S
Description
No description provided
Readme
865 KiB
Languages
Markdown 100%