- 12: `WHEN`/`AND` в блоке стыка правил — английские служебные слова там же, где `SHALL` отклонён как занятый OpenSpec; либо перевод, либо явная оговорка, что форма спецификации в этом месте намеренна - 13: критерий «названного вреда» для ДОЛЖЕН описан в языке, но правила под него нет — META-6 обязывает только понижать при отсутствии проверки - 14 и 15: одиннадцать таблиц не прочитаны на взаимоисключительность (подозреваемый SLOG-11), и канон не просмотрен на факты, записанные модальным словом
Канон конвенций
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
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. Двенадцать файлов канона всё ещё несут 31 пустой
именованный регион. Ни один репозиторий-потребитель не подключён, поэтому
переход никого не ломает.