av 7b2869d3a4 todo: заведена секция «Язык и подход» по итогам ревью
- девять находок ревью описания языка записаны вопросами 1–9: противоречие в
  условиях ДОЛЖЕН, неопределённая граница правила, примеры на живых
  идентификаторах, «тема» без реестра, ложное исключение GUIDE из проверок,
  МЕХАНИЗИРОВАНО без защиты нового подписчика, семантика слов вне копии,
  проверки без источника данных, натяжки в опоре на стандарты
- вопросы поделены на две секции: язык с подходом идёт первым, канон с
  тулингом вторым; «Мелкое» слито в вопрос 8, куда относилось по смыслу
- нумерация сплошная 1–15, внутренние ссылки переписаны под неё; в вопрос 12
  добавлена зависимость чекера от вопросов 2 и 8
2026-07-26 14:49:41 +03:00

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

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

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

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

Обвязка живёт только в каноне и в репозитории не оказывается — в копию едет лишь содержимое conventions/. Самодостаточность копии это не нарушает: конвенция называет язык записи одной строкой с номером версии и не ссылается на путь (LANGUAGE.md, раздел «Ссылка на язык из конвенции»).

Правило то же, что у ролей: деплоится и читается только то, что лежит в git репозитория. Канон никем не подключается на лету.

Направление — конвенция → код

Конвенция формулируется независимо от конкретного приложения. Она задаёт правило; код ему следует. Обратное направление запрещено: то, что приложение уже делает иначе, не является аргументом против правила — это отступление, и его место в локальной части копии того репозитория, а не в переформулировке канона.

Отсюда практические следствия:

  • в каноне нет утверждений о том, как что-то устроено в конкретном репозитории («у нас так в девяти плейбуках из тридцати трёх») — только нормы и условия их применимости;
  • в каноне нет списка, кто на что подписан: подписка — свойство репозитория, а не конвенции;
  • расхождение канона с кодом чинится либо кодом, либо честной записью отступления, либо — если правило оказалось неверным — правкой правила по существу, а не подгонкой под факт.

Оси

conventions/
  arch/            решения, переживающие смену языка и инструментов
  lang/<язык>/     как решение реализуется и механизируется в языке
  stack/<стек>/    привязка к инструменту, хранилищу, транспорту

Оси — раскладка канона; в репозитории копия лежит плоско, файлом на тему. Пути файлов даются относительно conventions/ (arch/db-identifiers.md) и адресуют исходник канона, а не место в копии. На правила ссылаются идентификатором без пути: KEYS-5. Префикс уникален по всему канону (реестр — prefixes.toml), поэтому идентификатор не зависит ни от оси, ни от того, как собран файл у потребителя.

Тест — по тому, замена чего убивает правило:

Умирает при смене языка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/ (типы колонок). Это не принцип, а незавершённая работа.

Префиксы

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

prefix: KEYS

Четыре заглавные латинские буквы, уникальные по всему канону; реестр — prefixes.toml. Префикс выбирается под файл, а не выводится по формуле, и не переиспользуется никогда. Правила адресуются идентификатором KEYS-5 — без пути к файлу. Подробности формы — LANGUAGE.md.

Буква X в начале префикса зарезервирована за репозиториями: канон её не занимает никогда, а локальные правила потребителя берут префиксы только на неё (XTIM, XLOG). Так столкновение локального префикса с будущим префиксом канона невозможно по построению, и согласовывать заранее ничего не нужно.

Расширение

Файл в lang/ или stack/ может объявить в шапке ещё и базу:

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/…

Так конвенция остаётся самодостаточным документом: тот, кто проверяет по ней код — человек или агент, — читает один файл и не собирает тему из трёх мест.

Шапка копии ставится при сборке и в каноне не хранится:

---
origin: time
---

Больше в шапке ничего нет. Отпечатка канона и даты синхронизации в ней не хранится: обновление перезаписывает файл в рабочем дереве, и что именно изменилось, показывает git diff до коммита. Второй механизм сравнения рядом с git не нужен.

Маркер локальной части — единственная машинно значимая разметка внутри файла:

<!-- conv:local -->

MIGR-2, MIGR-4 механизированы — `internal/archrules`.
MIGR-6 не соблюдается в `show_history`, `queue`: составные ключи там
появились до конвенции, миграция данных не окупается.

Всё ниже маркера принадлежит репозиторию и переживает обновление; всё выше — пересобирается из канона. Маркер один и безымянный, поэтому у него нет имени, которое можно осиротить переименованием.

Ниже маркера живёт то, чего канон о репозитории не знает: механизация, отступления, разрешение условий («Здесь: INTEGER PK, id наружу не выходят»), ссылки на ADR и код, а также собственные правилас префиксом на X, по тем же правилам формы, что и канон.

Если местных правок стало больше, чем каноничного текста, копия перестаёт быть копией: origin: из шапки убирают, и дальше это обычный документ репозитория. Файл, оставивший шапку, при следующем обновлении потеряет всё, что выше маркера.

Манифест

Откуда взяты копии и где брать обновления — .conventions.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.

Команды

conv list                    # какие темы есть в каноне
conv add time                # добавить тему в манифест и собрать файл
conv pull                    # пересобрать всё, что перечислено в манифесте

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

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