Compare commits

..
35 Commits
Author SHA1 Message Date
av 271603122d манифест приведён к машинному виду, объявлен governance
- добавлен ключ governance: без него конвенция, потерявшая topic, была
  неотличима от GUIDE.md и тихо теряла проверки об отъезде к потребителю
- комментарии из манифеста убраны — их всё равно съела бы первая же
  команда; то, чего не было в README.md, дописано туда
2026-07-28 10:16:39 +03:00
av 34d53d667d удалены TOOL.md и conv, инструкции переведены на convy
- TOOL.md был стартовой точкой разработки инструмента и свою задачу
  выполнил: решения о нём теперь живут в его собственном репозитории,
  открытые вопросы перенесены туда же
- питоновский conv собран под прежнюю модель копий (зеркальное дерево,
  именованные регионы, origin_hash) и удалён вместе с ней
- команды в README.md переписаны на convy, включая suite-сторону и sync
2026-07-28 10:05:22 +03:00
av 0d335ff58d манифест набора переименован в .conventions-suite.toml
- оба манифеста теперь данные инструмента: он их читает и переписывает
  целиком, комментариев они не держат — точка в начале ставит их рядом
  со служебными файлами, а не среди содержимого репозитория
- прежнее основание из TOOL.md («в наборе файл правится при каждой новой
  теме, и прятать его незачем») отпало: правит его convy
- ссылки в README.md, CLAUDE.md, TOOL.md и TODO.md обновлены
2026-07-28 09:54:52 +03:00
av 9a3a89358f logging: убран extends на чужую тему
- шапка `lang/go/logging.md` объявляла базой `arch/time.md` — копипаста
  из соседнего `lang/go/time.md`, единственного файла с этой базой
- у темы `logging` арх-слоя нет вовсе, расширять было нечего; ключ
  вернётся сам, когда невыделенное ядро уедет в `arch/logging.md`
- `convy suite check` на наборе проходит чисто
2026-07-28 09:48:56 +03:00
av 7fb60828db guide: граница со спекой переписана на тест наблюдаемости вердикта
- «что против как» на пограничных правилах не работает: capability
  проверяется снаружи работающей системы, конвенция — только в исходном
  тексте, и отсюда расходятся направление, распространение и шкала
- добавлен признак для спорного случая: обязательство перед внешним
  потребителем — в спеку, зависимость автора следующего патча — в конвенцию
2026-07-27 08:57:35 +03:00
av 9c86d9f2de компоненты как адресат сборки и плоский набор
- компонент — область репозитория, где выбранные слои действуют
  одновременно; сборка идёт по разу на компонент, у каждого своя директория
  копий, подписка и локальная часть, секции [components.<имя>] в манифесте
- плоский набор описан как низкий конец модели, а не отдельный режим: тема с
  одним слоем собирается копированием, ключи оси и lang/stack не пишутся
- в TODO заведён вопрос о реестре значений осей и судьбе extends:
2026-07-26 22:01:39 +03:00
av 787d0bb5ea guide: имя темы и объявленная ось — META-37, META-38
- META-37: тема называется решением и адресатом, а не ролью части проекта;
  логи сервера и браузера — logging и client-logging, а не суффиксная пара
- META-38: ось слоя объявляется ключами lang/stack в шапке, а не выводится
  из пути — переезд файла между директориями иначе молча менял состав копии
  у каждого потребителя; шапки двенадцати конвенций приведены к правилу
- в список проверок добавлены объявление оси, единственность базового слоя
  и совпадение объявленного с директорией
2026-07-26 22:01:38 +03:00
av 11fc9e1fee guide: заведены критерии границы темы — META-33…META-36
- тема определяется решением, а не веществом (META-33) и нужна потребителю
  целиком (META-34); слой сужает базу, но не отменяет её (META-35), иначе
  это другая тема, а вид приложения называется в области действия (META-36)
- добавлен раздел «Как проверить границу темы»: пять вопросов со ссылками
  на правила, включая META-20; те же строки в CLAUDE.md, а в LANGUAGE.md
  оговорка, что граница темы языку не принадлежит
- в TODO заведён прогон восьми тем по критериям с разбором подозреваемых:
  шесть правил time выносят вердикты чужих тем, logging мешает три страта
2026-07-26 21:25:24 +03:00
av b516bfb02c manifest.toml переименован в suite.toml
- родовое «манифест» заменено именем уровня: файл в наборе описывает сам
  набор, файл в проекте — подключённые конвенции, и каждый назван по тому,
  что описывает
- по имени рядом лежащего манифеста определяется контекст: suite.toml —
  набор, .conventions.toml — проект; в TOOL.md решение зафиксировано, из
  открытых вопросов убрано
2026-07-26 17:06:29 +03:00
av e8fdc98557 заведён TOOL.md — стартовая точка для convy
- имя, термины suite/project, раскладка команд: проектные наверху, ведение
  набора под подкомандой suite, check остаётся общим
- собрано в одном месте то, что инструмент делает и чего не делает: сборка
  копии, три группы проверок, независимость от конкретного набора, отказ от
  слияния, лока и обратного транспорта
- два тулинговых вопроса вынесены из TODO в раздел «Открытые вопросы»;
  вопросы про инструмент там больше не живут
2026-07-26 17:00:00 +03:00
av c96566d4b4 todo: заведён разбор шести сниппетов в блоках нормы
- GCFG-7, GCFG-9, SLOG-20, GTIM-8, HTMX-7, HTMX-24 несут код внутри нормы,
  то есть требуют ровно такой код; с появлением блока ПРИМЕРЫ часть из них
  туда и переезжает
- записана цена ошибки в обе стороны: деталь кода как требование против
  нормы, потерявшей обязательность в иллюстративном блоке
2026-07-26 16:14:26 +03:00
av 59a1c23f55 язык: заведён блок ПРИМЕРЫ
- пятая, необязательная часть правила: код парой «плохо → хорошо» после
  обоснования; метка добавлена в словарь (EXAMPLES) и в строку о версии
  языка во всех тринадцати файлах
- сказано, чем примеры не являются: требований в блоке нет, дословным
  сниппетом он не служит, при расхождении с нормой правят пример
- READING.md обновлён по META-30, в машинные проверки добавлен порядок
  блоков, в читательские — что примеры норму не расширяют
2026-07-26 16:09:58 +03:00
av 1d19e0357b язык: усиления источников названы своими
- три клетки «что взято» правились по факту: DMN даёт политику совпадения,
  но не требует полноты; в 29148 обоснование — рекомендуемый атрибут; вывод
  про обязательность шаблона в EARS не сформулирован
- заведён раздел «Где источник усилен»: полнота таблиц, обязательность
  обоснования и вывод из EARS предъявлены как наши решения с доводами
- поправлены два места, где та же натяжка повторялась прозой: «Таблицы
  решений» и «Обоснование обязательно»
2026-07-26 16:03:47 +03:00
av 682fa075bb снятое правило остаётся заглушкой, нумерация сплошная
- META-31 и META-32: номера идут без пропусков, ссылка обязана разрешаться;
  обе проверки стали механическими — данных со стороны языка им хватает
- заведена метка СНЯТО: заголовок и номер снятого правила сохраняются, норму
  с обоснованием заменяет блок с датой и причиной, отдельный реестр снятых
  номеров не нужен
- META-9, META-16 и META-26 переписаны из таблицы «Освободившиеся номера» в
  заглушки; три висячие ссылки, тянувшиеся с утра, закрылись
2026-07-26 15:59:22 +03:00
av 170c06c1da язык: короткое описание для читателя копии едет в репозиторий
- заведён READING.md: словарь со значениями, форма правила и её граница,
  ссылки, локальная часть — без разделов о ведении набора и без META-ссылок,
  примеры на X-префиксах
- сборщик кладёт его в docs/conventions/ и перезаписывает целиком; в манифест
  набора добавлена секция [language] с версией и двумя документами
- META-30: правка словаря или состава частей правила доходит до READING.md,
  иначе потребитель толкует ДОПУСКАЕТСЯ по прежней версии
2026-07-26 15:51:54 +03:00
av 67d51db212 механизация больше не разрешает удалять норму
- META-9 снят: удаление оставляло подписчика, пришедшего после, без нормы и
  без проверки, а «механизировано у всех» канону не проверить — списка
  подписчиков у него нет по построению
- META-8 переписан в запрет: норма остаётся в правиле, чем бы её ни
  проверяли; линтер сообщает, что нарушено, но не что требуется (META-6)
- МЕХАНИЗИРОВАНО объявлена свойством репозитория: в тексте конвенции отметки
  нет, её место — запись о механизации ниже маркера (META-7)
2026-07-26 15:41:52 +03:00
av fe61ecd6c5 guide: язык употребляется, значит и проверяется как в конвенции
- проверки разведены по роли слова: то, что язык употребляет (конвенции и
  GUIDE.md), проверяется; то, что цитирует (LANGUAGE.md, README.md), — нет
- список машинных проверок разбит на форму правила и распространение:
  вторая группа (тема в шапке, пути канона, чужие префиксы, локальные X)
  касается только того, что едет к потребителю
- в GUIDE.md добавлена строка о версии языка и убрано заглавное СЛЕДУЕТ из
  вводной прозы «Оформления» — единственное нарушение, которое исключение
  прятало
2026-07-26 15:34:49 +03:00
av c1cb240540 темы: определение, объявление в шапке и манифест набора
- тема — набор правил об одном фокусе разработки, имя латиницей (нижний
  kebab-case рекомендуется, годится любой идентификатор, пригодный для имени
  файла); определение в LANGUAGE.md и README.md
- заведены META-28 и META-29: тема объявляется в шапке (`topic:`), стоит в
  манифесте набора и не переиспользуется; `topic:` добавлен во все 12 файлов
- prefixes.toml и topics.toml слиты в manifest.toml — манифест набора против
  манифеста подключения `.conventions.toml`, разделы topics/prefixes с live
  и retired
2026-07-26 15:27:55 +03:00
av d5118336cb язык: примеры переведены на вымышленные X-правила
- живые идентификаторы KEYS-5, SLOG-8.1, SLOG-27, MIGR-2/4/6 в примерах
  заменены на XKEY, XMIG, XLOG: номера канона означали не то, что в примере,
  и расходились дальше при каждой перенумерации
- сказано явно, что описание языка ни на один набор конвенций не опирается;
  ссылка на обоснования канона в разделе про ПОЧЕМУ заменена на внешние
  практики
2026-07-26 15:15:30 +03:00
av df8c58671f язык: объявлена граница правила
- область правила — от его заголовка до следующего заголовка любого уровня;
  метка открывает блок, хвост после ПОЧЕМУ — продолжение обоснования, а
  таблица после модальной метки — часть нормы
- проверка «заглавных модальных слов вне правил нет» стала реализуемой:
  прозой считается то, что лежит вне областей правил
- нормы, сидевшие в хвостах, подняты в блок нормы: заведены SLOG-25.4 и
  GERR-26.3, у GTIM-12 «базовый слой» заменён на TIME-12
2026-07-26 15:10:59 +03:00
av 4943bf1dd2 второе условие ДОЛЖЕН ослаблено до воспроизводимости вердикта
- META-6 переписан: ступень требует не машинной проверки, а того, чтобы
  двое проверяющих по тексту правила выносили один вердикт; проверяющий по
  умолчанию — читатель, человек или агент
- заведён META-27: машинная проверка желательна везде, где пишется, но
  ступени не задаёт — иначе канон стоит в СЛЕДУЕТ до появления скриптов;
  обоснование META-25 пересобрано на новом условии
- LANGUAGE.md и CLAUDE.md приведены к той же формулировке, вопрос 1 из
  TODO закрыт, остальные перенумерованы
2026-07-26 15:01:51 +03:00
av 7b2869d3a4 todo: заведена секция «Язык и подход» по итогам ревью
- девять находок ревью описания языка записаны вопросами 1–9: противоречие в
  условиях ДОЛЖЕН, неопределённая граница правила, примеры на живых
  идентификаторах, «тема» без реестра, ложное исключение GUIDE из проверок,
  МЕХАНИЗИРОВАНО без защиты нового подписчика, семантика слов вне копии,
  проверки без источника данных, натяжки в опоре на стандарты
- вопросы поделены на две секции: язык с подходом идёт первым, канон с
  тулингом вторым; «Мелкое» слито в вопрос 8, куда относилось по смыслу
- нумерация сплошная 1–15, внутренние ссылки переписаны под неё; в вопрос 12
  добавлена зависимость чекера от вопросов 2 и 8
2026-07-26 14:49:41 +03:00
av 4cd0c97ed0 язык остался версией 1, правило движения номера записано
- бумп до 2 откачен: копий в природе нет, читать по версии 1 пока нечему, и
  номер сжигать незачем
- вместо истории версий записано правило: номер двигается, когда изменение
  формы способно изменить чтение уже разданной копии; правки формы до раздачи
  копий его не двигают, а смена словаря под другой язык — не двигает никогда
2026-07-26 14:30:36 +03:00
av 72d77d74bf ПОЧЕМУ стало ключевым словом, язык поднят до версии 2
- метка обоснования пишется заглавными и вошла в словарь набора: скелет
  правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и
  `**Почему.**`; в переводе на другой язык метка меняется как остальные слова
  (ПОЧЕМУ / WHY), 235 вхождений заменены
- метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и
  МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не
  даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице
  модальности
- версия языка поднята до 2, потому что изменение формы меняет чтение уже
  написанного текста; строка о версии в двенадцати конвенциях перечисляет
  теперь и метки, а служебные слова сценария в неё по-прежнему не входят
2026-07-26 14:28:37 +03:00
av c8071dc438 guide: META-26 снят, форма обоснования рамками не ограничивается
- запрет слов обязательства в «Почему» был лишним по построению: правило о
  заглавных уже делает строчное «обязан» ненормативным, так что второй копии
  нормы не возникает, а цена — автор воюет со списком слов вместо объяснения
- в LANGUAGE записано прямо: рамки у обоснования только смысловые, длина,
  рассуждение, примеры и ссылки на внешние практики и чужие проекты допустимы
- заведён раздел «Освободившиеся номера»: META-16 и META-26 с причинами. Без
  такого списка упоминание номера в прозе не отличить от ссылки на исчезнувшее
  правило — это же нужно будущей проверке ссылок
2026-07-26 14:22:38 +03:00
av 3e0ec46134 guide: заведён META-26 — обоснование объясняет, а не требует
- «Почему» не пересказывает норму словами обязательства: у оригинала есть
  идентификатор, у копии нет, и расходятся они при первой правке оригинала, а
  отступление от копии адресовать нечем
- модальность рекомендательная по META-6: греп находит слово, а не нарушение
  — «за попыткой следует повтор» и «становится обязанностью вызывающего»
  описывают ход событий; утверждения о невозможности («нельзя») правилом не
  затрагиваются, это ISO-евская возможность в прозе
- SLOG-12 починен: факт о том, что `slog` не разделяет CRITICAL и FATAL, уехал
  из нормы в «Почему», а норма теперь говорит то же, что заголовок
2026-07-26 14:18:06 +03:00
av 98fc69d585 guide: заведён META-25 — высшая модальность требует названного вреда
- ДОЛЖЕН выбирается, только когда в «Почему» сказано, что ломается при
  нарушении: до сих пор обязывало лишь второе условие (META-6, машинная
  проверка), и любую проверяемую мелочь можно было пометить ДОЛЖЕН
- модальность самого правила — СЛЕДУЕТ, и это не слабость: «вред назван»
  устанавливается чтением, значит по META-6 иначе и быть не может
- kebab-case в именах файлов пересобран как пример META-25: проверяется
  регуляркой тривиально, но вреда нет — потому правилом и не записан
2026-07-26 14:07:54 +03:00
av 3fce663d7a служебные слова сценарного блока стали частью словаря набора
- `КОГДА`, `ТОГДА`, `И`, `ИЛИ` (по-английски WHEN/THEN/AND/OR) подчиняются тем
  же требованиям, что модальные слова: одна форма на роль, заглавными, набор
  один на канон и выбирается под естественный язык
- модальностью они не являются — обязательности не задают, только структуру,
  поэтому в строку о версии языка не попадают и под проверку «модальные слова
  вне правил» не подпадают
- единственный сценарный блок канона (SLOG, «Два цикла повтора») переписан с
  WHEN/AND на русские связки; отказ от `GIVEN/WHEN/THEN` уточнён — он касается
  общей формы записи, а на стыке правил эта форма взята сознательно
2026-07-26 14:03:29 +03:00
av f99a513058 guide: заведён META-24 — ссылка слоя на идентификаторы своей базы
- языковой и стековый слои называют идентификатор правила арх-слоя своей темы
  прямо в норме: подписываются темой, а не слоем, поэтому базовый слой в
  собранной копии присутствует всегда и ссылка не ведёт в пустоту
- разрешение узкое по построению — на слои других языков и стеков не
  распространяется, их состав в копии зависит от манифеста
- из META-21 убрано предписание ссылаться на свой слой словами «базовый
  слой»: слова не проверяются и не ведут к утверждению, а идентификатор ведёт
2026-07-26 13:56:43 +03:00
av 7fca0e8cb8 из канона удалены пустые локальные регионы
- 31 регион `<!-- local:имя -->` в двенадцати файлах удалён, а не перенесён:
  локальное принадлежит копии и живёт ниже маркера `<!-- conv:local -->`
  (META-22), так что наполнять регионы в каноне нечем
- вместе с ними ушли два опустевших раздела «Связано» — в arch и ansible
  слоях app-directories канонических ссылок нет, а пустой заголовок ничего
  не адресует; CLAUDE.md уточнён: раздел заводят, когда ссылки есть
- форма проверена скриптом: у всех правил модальность и «Почему», префиксы
  сходятся с реестром, дыр в нумерации нет
2026-07-26 13:52:07 +03:00
av a961aa2b40 todo: закрытые вопросы удалены, оставшиеся перенумерованы
- ушли четыре закрытых раздела (ссылка на язык, модель сборки, разбор
  прототипов, мультиязычность) и закрытые куски внутри оставшихся: принятые
  решения живут в README, GUIDE и LANGUAGE, а черновик их дублировал
- полезные остатки перенесены, а не потеряны: прототипы Vale и vendir — в
  вопрос про тулинг, вынос арх-ядра из errors и logging — в пары слоёв
- нумерация сплошная 1–11, внутренние ссылки поправлены под неё; в шапку
  добавлено правило, что закрытый вопрос отсюда удаляется
2026-07-26 13:46:48 +03:00
av c04a54ffd5 todo: заведены четыре открытые темы по языку записи
- 12: `WHEN`/`AND` в блоке стыка правил — английские служебные слова там же,
  где `SHALL` отклонён как занятый OpenSpec; либо перевод, либо явная
  оговорка, что форма спецификации в этом месте намеренна
- 13: критерий «названного вреда» для ДОЛЖЕН описан в языке, но правила под
  него нет — META-6 обязывает только понижать при отсутствии проверки
- 14 и 15: одиннадцать таблиц не прочитаны на взаимоисключительность
  (подозреваемый SLOG-11), и канон не просмотрен на факты, записанные
  модальным словом
2026-07-26 13:43:45 +03:00
av 9318087248 язык записи опёрт на стандарты, словарь стал параметром
- шкала обязательности объявлена инвариантом, а набор ключевых слов —
  параметром естественного языка набора: для английского готовый словарь
  даёт BCP 14, для прочих берут перевод стандарта или делают свой; отклонены
  синонимы ступеней и `SHALL`, занятый OpenSpec
- применены шесть дельт: нормативно только заглавное написание (RFC 8174),
  ДОЛЖЕН требует названного вреда и машинной проверки сразу, ДОПУСКАЕТСЯ
  адресовано рецензенту, МЕХАНИЗИРОВАНО выведено из шкалы в отметку рядом с
  модальностью (ISO/IEC/IEEE 29148), у таблиц объявлены политика совпадения
  и полнота (DMN)
- «Форма записи — LANGUAGE.md» в двенадцати конвенциях заменена строкой о
  версии языка по образцу boilerplate BCP 14: пути канона в копии не
  существует, а словарь и правило заглавных строка несёт сама
2026-07-26 13:42:10 +03:00
av fea0285619 todo: закрыт вопрос про модель сборки, разобраны прототипы
- вопрос 3 закрыт: модель записана в README и GUIDE, отмечены три места,
  где итог разошёлся с прежними заготовками — нет лока, нет маркеров секций,
  локальные префиксы не объявляются, а резервируются буквой `X`
- вопрос 4 переформулирован: регионы не переносятся в единую секцию, а
  удаляются из канона — наполнять их в каноне нечем
- вопрос 7 заменён разбором: транспорт переизобретался, оси и сборка аналога
  не имеют, Vale отклонён; в 1, 9 и 10 добавлены следствия
2026-07-26 12:55:16 +03:00
av c2f68e6be1 обвязка: модель копий переписана под один маркер и манифест
- именованные регионы `<!-- local:имя -->` заменены на единственный
  `<!-- conv:local -->`: всё ниже него принадлежит репозиторию, всё выше
  пересобирается, поэтому имени-которое-можно-осиротить больше нет
- лок-файла и `origin_hash` в шапке нет — «что было в прошлый раз» знает git,
  копии закоммичены, автоматического обновления не существует; транспорт
  назад (`push`) убран вместе с ними
- заведены META-22 (репозиторное пишется ниже маркера) и META-23 (форк не
  носит `origin:`), META-17 переписан под маркер; буква `X` в префиксе
  зарезервирована за локальными правилами потребителей
2026-07-26 12:55:05 +03:00
21 changed files with 1977 additions and 1472 deletions
+34
View File
@@ -0,0 +1,34 @@
governance = "GUIDE.md"
[language]
version = 1
lang = "ru"
description = "LANGUAGE.md"
reading = "READING.md"
[topics]
[topics.live]
app-directories = "категории директорий приложения и что в каждой лежит"
config = "конфигурация: файл, валидация, секреты"
db-identifiers = "идентификаторы сущностей: вид ключа, генерация, границы"
db-schema = "схема БД и миграции: типы колонок, форма изменения"
errors = "ошибки: обёртки, границы трансляции, паники"
logging = "логирование: уровни, структура записи, что не логируем"
time = "время: хранение, зоны, форматы, календарные границы"
web-ui = "веб-UI: партиалы, свопы, поллинг"
[prefixes]
[prefixes.live]
ANSD = "conventions/stack/ansible/app-directories.md"
CONF = "conventions/arch/config.md"
DIRS = "conventions/arch/app-directories.md"
GCFG = "conventions/lang/go/config.md"
GERR = "conventions/lang/go/errors.md"
GKEY = "conventions/lang/go/db-identifiers.md"
GTIM = "conventions/lang/go/time.md"
HTMX = "conventions/stack/htmx/web-ui.md"
KEYS = "conventions/arch/db-identifiers.md"
META = "GUIDE.md"
MIGR = "conventions/lang/go/db-schema.md"
SLOG = "conventions/lang/go/logging.md"
TIME = "conventions/arch/time.md"
+143 -41
View File
@@ -9,8 +9,9 @@ code in this repository.
Канон конвенций разработки для личных проектов. Сами конвенции лежат в Канон конвенций разработки для личных проектов. Сами конвенции лежат в
`conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`, `conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`,
`LANGUAGE.md`, `GUIDE.md`, `prefixes.toml`, `conv`) живёт в корне и в `LANGUAGE.md`, `GUIDE.md`, `READING.md`, `.conventions-suite.toml`) живёт в
репозитории-потребители не едет. корне. К потребителю из неё едет только `READING.md` — короткое описание языка
для читателя копий.
Ниже — короткие инварианты с идентификаторами; детали и обоснования в Ниже — короткие инварианты с идентификаторами; детали и обоснования в
`LANGUAGE.md` (форма записи) и `GUIDE.md` (процесс, префикс META). `LANGUAGE.md` (форма записи) и `GUIDE.md` (процесс, префикс META).
@@ -18,45 +19,101 @@ code in this repository.
## Форма правила ## Форма правила
- Четыре обязательные части: `### <ПРЕФИКС>-<N>. Заголовок`, абзац - Четыре обязательные части: `### <ПРЕФИКС>-<N>. Заголовок`, абзац
`**МОДАЛЬНОСТЬ.** норма`, абзац `**Почему.** …`. Правило без «Почему» не `**МОДАЛЬНОСТЬ.** норма`, абзац `**ПОЧЕМУ.** …`. Правило без обоснования не
принимается. принимается.
- `**ПРИМЕРЫ.**` — необязательный пятый блок после обоснования: код парой
«плохо → хорошо». Иллюстрация нормы, а не спецификация — требований в блоке
нет, дословным сниппетом он не является, при расхождении действует норма.
- Норма — одна фраза; если в неё не влезает, это два правила. - Норма — одна фраза; если в неё не влезает, это два правила.
- Модальные слова: **ДОЛЖЕН**, **НЕ ДОЛЖЕН**, **СЛЕДУЕТ**, **НЕ СЛЕДУЕТ**, - Область правила — от его заголовка до следующего заголовка любого уровня;
**ДОПУСКАЕТСЯ**, плюс не-модальная отметка **МЕХАНИЗИРОВАНО**. Английские метка открывает блок, блок длится до следующей метки или до конца области.
ключевые слова (SHALL, MUST) не используются — они заняты OpenSpec. Абзацы после `**ПОЧЕМУ.**` — продолжение обоснования: требований в них не
- Модальные слова не употребляются вне правил: ни в «Область действия», ни в живёт, требование ставят в блок нормы. Таблица и список после модальной
«Связано», ни в локальных регионах, ни во вводной прозе. метки — часть нормы.
- «Почему» отвечает на «что сломается, если сделать иначе», а не - Шкала инвариантна, словарь — параметр языка набора. Канон русский, значит
слова: **ДОЛЖЕН**, **НЕ ДОЛЖЕН**, **СЛЕДУЕТ**, **НЕ СЛЕДУЕТ**,
**ДОПУСКАЕТСЯ**. Словарь один на канон, синонимов на ступень нет.
- `SHALL` не используется ни в одном словаре — занято OpenSpec.
- Нормативно только заглавное написание (правило RFC 8174): строчное
«должен» в прозе нормой не является.
- ДОЛЖЕН требует двух условий сразу: назван вред от нарушения (META-25) и
вердикт о нарушении воспроизводим (META-6). Воспроизводимость сама по себе
до ДОЛЖЕН не повышает — иначе шкала наполняется проверяемыми мелочами.
- ДОПУСКАЕТСЯ адресовано рецензенту: помеченный им выбор на ревью не
обсуждается.
- **МЕХАНИЗИРОВАНО** — не модальность, а способ проверки, и свойство
репозитория, а не канона: в тексте конвенции отметки нет, она стоит при
записи о механизации в локальной части копии (META-7).
- META-8: норма не удаляется из канона никогда, чем бы её ни проверяли.
Механизация её не заменяет и не сокращает.
- Метки правила — **ПОЧЕМУ**, **ПРИМЕРЫ**, **МЕХАНИЗИРОВАНО** и **СНЯТО**
тоже словарь набора и перечислены в строке о версии языка наравне с
модальными словами.
- META-30: правка словаря или состава частей правила доходит до `READING.md`
документа, который едет к потребителю. Словари двух описаний совпадают.
- Заглавные модальные слова не употребляются вне правил: ни в «Область
действия», ни в «Связано», ни в локальной части копии, ни во вводной прозе.
Исключение — строка о версии языка, которая их перечисляет.
- Обоснование отвечает на «что сломается, если сделать иначе», а не
пересказывает норму. «Потому что так принято» — не обоснование. пересказывает норму. «Потому что так принято» — не обоснование.
- Форма обоснования не ограничена: рамки смысловые. Длина, рассуждение,
примеры, ссылки на внешние практики и чужие проекты — всё допустимо;
запрещённых слов нет. Обязательность несёт норма, и путаницу исключает
правило о заглавных.
- Служебные слова сценарного блока — тоже словарь набора: **КОГДА**,
**ТОГДА**, **И**, **ИЛИ** (по-английски `WHEN`/`THEN`/`AND`/`OR`). Одна
форма на роль, заглавными. Модальностью не являются, в строку о версии
языка не попадают.
- Правило, классифицирующее ситуации, пишется таблицей «ситуация → вердикт»; - Правило, классифицирующее ситуации, пишется таблицей «ситуация → вердикт»;
строки нумеруются `KEYS-5.1`. строки нумеруются `KEYS-5.1`. Строки взаимоисключающи по умолчанию; иной
порядок объявляется явно, а перечисленные случаи покрывают область
действия.
- Модальность принадлежит правилу, а не файлу: `status:` в шапке отменён. - Модальность принадлежит правилу, а не файлу: `status:` в шапке отменён.
## Идентификаторы и префиксы ## Идентификаторы: тема и префикс
- Тема — набор правил об одном фокусе разработки и единица подписки. Имя —
латиницей, рекомендуется нижний kebab-case, годится любой идентификатор,
пригодный для имени файла.
- META-28: тема объявлена в шапке (`topic: time`) и стоит в манифесте набора
(`.conventions-suite.toml`, секция `[topics.live]`). Слои одной темы несут
одно имя — по нему собираются в один файл, как бы ни назывались их файлы;
имя файла повторяет тему из удобства.
- META-29: имя темы не переиспользуется, снятое уходит в `[topics.retired]`
с причиной и датой. Оно живёт в `origin:` копий и в подписках манифестов.
- Формат `<ПРЕФИКС>-<номер>`, нумерация сквозная внутри файла. Порядок правил - Формат `<ПРЕФИКС>-<номер>`, нумерация сквозная внутри файла. Порядок правил
в файле — по читаемости: номер это идентификатор, а не позиция. в файле — по читаемости: номер это идентификатор, а не позиция.
- Идентификаторы не переиспользуются. Удалённое правило оставляет дыру, новое - Идентификаторы не переиспользуются: новое правило берёт номер, следующий за
берёт следующий свободный номер, а не первый освободившийся. наибольшим.
- META-31: нумерация в файле сплошная. Снятое правило не исчезает, а остаётся
заглушкой: заголовок с номером плюс блок `**СНЯТО <дата>.**` с причиной
вместо нормы и ПОЧЕМУ. Реестра снятых номеров нет — файл сам себе реестр.
- META-32: ссылок на несуществующие правила нет; неразрешённый идентификатор —
всегда ошибка, а не «правило, наверное, сняли».
- Новый файл конвенции — новый префикс: четыре заглавные латинские буквы, - Новый файл конвенции — новый префикс: четыре заглавные латинские буквы,
уникальные по всему канону, выбираются под файл, а не выводятся по формуле. уникальные по всему канону, выбираются под файл, а не выводятся по формуле.
Объявляется в шапке (`prefix: KEYS`) и регистрируется в `prefixes.toml`, Объявляется в шапке (`prefix: KEYS`) и регистрируется в манифесте набора,
секция `[live]`, путём от корня репозитория. секция `[prefixes.live]`, путём от корня репозитория.
- Удаление или разделение файла: префикс уходит в `[retired]` с причиной и - Удаление или разделение файла: префикс уходит в `[prefixes.retired]` с
датой, а не освобождается. причиной и датой, а не освобождается.
- Префиксы на букву `X` канон не занимает: они зарезервированы за локальными
правилами репозиториев-потребителей.
- Перенос правила в другой файл — смысловое изменение: новый префикс и новый - Перенос правила в другой файл — смысловое изменение: новый префикс и новый
номер. Переезд самого файла между осями идентификаторы не трогает. номер. Переезд самого файла между осями идентификаторы не трогает.
## Ссылки ## Ссылки
- META-20: норму можно исполнить, имея один этот файл. Ссылка на правило - META-20: норму можно исполнить, имея один этот файл. Ссылка на правило
чужой темы допустима в «Почему», в «Связано» и в разграничении области чужой темы допустима в обосновании, в «Связано» и в разграничении области
действия — но не в самой норме. Нужен концепт соседней темы — коротко действия — но не в самой норме. Нужен концепт соседней темы — коротко
повторить его здесь, соседа назвать в «Почему». повторить его здесь, соседа назвать в обосновании.
- META-21: на соседнюю конвенцию ссылаются именем темы (конвенция - META-21: на соседнюю конвенцию ссылаются именем темы (конвенция
`logging`), на правило — идентификатором (`SLOG-27`), на другой слой своей `logging`), на правило — идентификатором (`SLOG-27`). Пути файлов канона в
темы — словами «базовый слой». Пути файлов канона в тексте конвенции нет тексте конвенции нет (в обвязке — можно).
(в обвязке — можно). - META-24: слой `lang/` или `stack/` называет идентификатор правила арх-слоя
**своей** темы прямо в норме — базовый слой в собранной копии всегда рядом.
На слои других языков и стеков это не распространяется: их состав зависит
от манифеста.
## Что в каноне писать нельзя ## Что в каноне писать нельзя
@@ -65,16 +122,37 @@ code in this repository.
- META-5: расхождение кода с правилом — отступление, а не повод переписать - META-5: расхождение кода с правилом — отступление, а не повод переписать
правило. Направление всегда конвенция → код; факт «в приложении уже иначе» правило. Направление всегда конвенция → код; факт «в приложении уже иначе»
не является аргументом. не является аргументом.
- META-6: ДОЛЖЕН без механической проверки либо механизируется, либо - META-6: ДОЛЖЕН требует воспроизводимого вердикта — двое проверяющих по
понижается в СЛЕДУЕТ. Правило, непроверяемое машиной в принципе (вкус тексту правила отвечают одинаково. Правило, вердикт которого зависит от
формулировки, суждение о ситуации), — СЛЕДУЕТ по построению. суждения (вкус формулировки, уместность в конкретном месте), — СЛЕДУЕТ по
- META-10: блок «Почему» не удаляется никогда, в том числе после того, как построению. META-27: машинная проверка желательна, но ступени не задаёт;
норма уехала в линтер. проверяющий по умолчанию — читатель правила, человек или агент.
- META-10: блок ПОЧЕМУ не удаляется никогда, в том числе после того, как
правило стало проверяться линтером.
- META-1: один файл — ровно один повторяющийся выбор. META-2: конвенция - META-1: один файл — ровно один повторяющийся выбор. META-2: конвенция
заводится, когда решение принимается третий раз. заводится, когда решение принимается третий раз.
- Локальные регионы `<!-- local:имя --> … <!-- /local -->` в каноне остаются - Репозиторного в каноне нет вовсе: механизация, отступления и ссылки на код
пустыми: их содержимое принадлежит репозиторию-потребителю. Имя региона и живут в копии ниже маркера `<!-- conv:local -->` (META-22), который ставит
путь файла — API, переименование осиротит все копии. сборщик. Заводить пустые местные разделы в каноне не нужно.
## Граница темы
Пять вопросов, по которым тему проверяют на «не хапнули ли лишнего»; сначала
разрез темы, ось — потом.
- META-33: правило стоит в теме, чей вопрос оно решает. Тема — решение, а не
вещество: «время» проходит через несколько решений сразу, и правило о
колонках БД принадлежит схеме, а не времени.
- META-37: имя темы называет решение и адресата, а не роль части проекта:
`logging` и `client-logging`, но не `logging-backend`/`logging-frontend`.
- META-34: тема нужна потребителю целиком — подписка берёт её без остатка.
Если два правдоподобных потребителя хотят непересекающиеся части, между
ними и проходит граница.
- META-35: слой сужает базу, но не отменяет её. Приходится отменять — это не
слой, а другая тема; общим осталось слово, а не решение.
- META-36: вид приложения (веб-сервис, CLI, плейбуки, библиотека) называется
в области действия, если норма от него зависит. Осью он не является.
- META-20: норма исполнима без соседних тем.
## Выбор оси ## Выбор оси
@@ -82,15 +160,31 @@ code in this repository.
хранилища или транспорта → `stack/<стек>/`. Не умирает ни от того, ни от хранилища или транспорта → `stack/<стек>/`. Не умирает ни от того, ни от
другого → `arch/`. Ось определяется природой правила, а не числом сегодняшних другого → `arch/`. Ось определяется природой правила, а не числом сегодняшних
потребителей. `extends: arch/<файл>.md` в шапке — документация связи, а не потребителей. `extends: arch/<файл>.md` в шапке — документация связи, а не
механизм; слой только реализует и сужает базу, но не отменяет её. механизм; слой только реализует и сужает базу, но не отменяет её (META-35).
META-38: ось объявлена в шапке ключами `lang:` и `stack:`, а не выведена из
пути; без обоих ключей файл — базовый слой темы. Директория повторяет
объявленное для человека. Осей может не быть вовсе: набор, где у темы один
слой, — низкий конец той же модели, а не особый режим.
## Компоненты
Компонент — область репозитория, где все выбранные слои действуют
одновременно (`sqlite` и `postgres` — да, go и javascript — никогда). Уровней
три: набор → проект → компонент. Сборка прогоняется по разу на компонент, у
каждого своя директория копий, своя подписка и своя локальная часть; в
`.conventions.toml` они записаны секциями `[components.<имя>]` с ключами
`dir`, `lang`, `stack`, `topics`. Компонент пишется всегда, даже когда он
один. Директории компонентов различны — этим копии и разводятся.
## Оформление файла ## Оформление файла
Шапка `prefix:` (плюс `extends:`) → `# Тема` → вводная проза со строкой Шапка `topic:` и `prefix:` (плюс `extends:`) → `# Тема` → вводная проза
«Форма записи — `LANGUAGE.md`» → `## Область действия` (обязателен для отдельным абзацем строка о версии языка (её точный текст — в `LANGUAGE.md`,
трудноизменяемых слоёв — META-11) → правила → `## Связано` с пустым раздел «Ссылка на язык из конвенции») → `## Область действия` (обязателен для
`<!-- local:связано -->`. Имя файла — kebab-case по теме. Проза переносится трудноизменяемых слоёв — META-11) → правила → `## Связано`, если канонические
по ~76 колонок; таблицы и блоки кода не переносятся. ссылки есть (META-17; пустого раздела не заводят). Имя файла повторяет имя
темы. Проза переносится по ~76 колонок; таблицы и блоки кода не переносятся.
## Ревью формы ## Ревью формы
@@ -98,6 +192,12 @@ code in this repository.
проверять машиной». Ни одна из проверок не реализована, поэтому при ревью их проверять машиной». Ни одна из проверок не реализована, поэтому при ревью их
выполняют чтением. выполняют чтением.
Проверяется всё, что язык **употребляет**: конвенции и `GUIDE.md` (он несёт
правила META и строку о версии языка). `LANGUAGE.md` и `README.md` язык
цитируют — ключевые слова в них предмет описания, а не норма. Проверки
распространения (тема в шапке, самодостаточность нормы, отсутствие путей
канона) касаются только конвенций: обвязка к потребителю не едет.
## Коммиты ## Коммиты
Русский, строчная буква, без точки в конце, прошедшее время или страдательный Русский, строчная буква, без точки в конце, прошедшее время или страдательный
@@ -108,12 +208,14 @@ code in this repository.
## Состояние репозитория ## Состояние репозитория
- Тестов, линтеров и CI нет. `conv` — python3 CLI на одной stdlib; запускают - Тестов, линтеров и CI здесь нет: репозиторий — данные, а не код. Проверяет
его из корня репозитория-потребителя (`~/projects/private/dev-conventions/` их `convy suite check`, живущий в своём репозитории и ставящийся бинарём.
плюс `conv status`). `status` и `diff` всегда возвращают 0 — это отчёт, а не - Модель копий, описанная в `README.md`, реализована в `convy`. Прежний
проверка. питоновский `conv` удалён вместе со своей моделью (зеркальное дерево,
именованные регионы, `origin_hash`). При расхождении обвязки с инструментом
истина — README, а не код.
- Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:` - Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:`
в природе нет, все локальные регионы канона пусты. в природе нет.
- `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при - `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при
работе над обвязкой его стоит прочесть, но истина о текущем устройстве — работе над обвязкой его стоит прочесть, но истина о текущем устройстве —
`README.md`. `README.md`.
+379 -96
View File
@@ -12,6 +12,13 @@ prefix: META
[LANGUAGE.md](LANGUAGE.md). Здесь — про то, зачем конвенции заводятся, где [LANGUAGE.md](LANGUAGE.md). Здесь — про то, зачем конвенции заводятся, где
живут и как соотносятся с соседними видами документов. живут и как соотносятся с соседними видами документов.
Правила ниже записаны тем же языком, что и сами конвенции, и проверяются так
же.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
@@ -23,34 +30,73 @@ prefix: META
- `docs/adr/`**решение**, принятое однажды и постфактум («почему выбрали - `docs/adr/`**решение**, принятое однажды и постфактум («почему выбрали
Authelia, а не Keycloak»). Запись неизменяема. Authelia, а не Keycloak»). Запись неизменяема.
- `docs/specs/` и OpenSpec, где они есть, — **что** система делает, - `docs/specs/` и OpenSpec, где они есть, — контракт наблюдаемого поведения.
наблюдаемое поведение как контракт. Конвенция — **как** написан код; Конвенция в спеки не переносится: это не capability.
в спеки она не переносится, это не capability.
- `docs/drafts/` — оперативная хроника и черновики, «что собираюсь сделать». - `docs/drafts/` — оперативная хроника и черновики, «что собираюсь сделать».
- `docs/conventions/`**правило на будущее**, применяемое многократно. - `docs/conventions/`**правило на будущее**, применяемое многократно.
Живой документ: правится, когда договорённость меняется. Живой документ: правится, когда договорённость меняется.
Со спекой конвенцию путают чаще прочего, а «что против как» на границе не
работает. Разводит их то, **где наблюдается вердикт**. У capability он виден
снаружи работающей системы: подали вход, получили выход, совпало или нет. У
конвенции — только в исходном тексте: снаружи не различить, обёрнута ошибка
или проглочена и по какому признаку выбран уровень записи.
Отсюда расходится остальное. Спека едет за системой — изменилось поведение,
меняется контракт; конвенция ведёт код, и факт «в приложении уже иначе»
аргументом не считается (META-5), а утверждений о состоянии репозитория в ней
нет вовсе (META-4). Спека принадлежит одной системе; конвенция ездит копиями
и потому знает про темы, слои и локальную часть. Capability бинарна —
реализована или нет; у конвенции есть ступени и постоянный список отступлений
(META-13). Спеку пишут до кода, конвенцию — на третий раз (META-2).
Пограничное правило разбирается признаком внешнего потребителя. Формат логов,
который собирает чужой агрегатор, — обязательство перед кем-то снаружи, и
место ему в спеке. Если от правила зависит только автор следующего патча —
это конвенция.
## Оформление ## Оформление
Имя файла — kebab-case по теме: `app-directories.md`. Правилом это не Имя файла повторяет имя темы: `app-directories.md`. Правилом это не
записано: обоснование сводится к «чтобы имя файла в реестре префиксов записано, и это случай META-25: регуляркой имя проверяется тривиально, но
писалось одним способом», а проверить нарушение всё равно проще глазом, чем вреда от нарушения нет — тема объявлена в шапке (META-28), и сборка идёт по
сформулировать норму. Номер META-16, под которым это правило существовало, объявленному имени, а не по имени файла. Значит, и высшей модальности нет, а
оставлен свободным и не переиспользуется. ступенью ниже такое правило не окупает строчку.
## Канон и копии ## Канон и копии
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
`dev-conventions`, а не собственные документы репозитория. Репозиторное `dev-conventions`, а не собственные документы репозитория. Копия собирается
живёт только внутри локальных регионов `<!-- local:имя --> … <!-- /local -->`: из канона целиком, поэтому репозиторное живёт ниже маркера локальной части
они исключены из сравнения с каноном, и расхождение по ним — норма, а не в конце файла: обновление сохраняет всё, что ниже маркера, и переписывает
дрейф. Правка вне регионов означает одно из двух: улучшение, которое всё, что выше. Откуда взяты копии и где брать обновления — в манифесте
возвращают в канон, или сознательное расхождение, записанное в ключ `local:` `.conventions.toml` в корне репозитория.
шапки. Состояние копий показывает `conv status`, различия — `conv diff`,
обновление из канона — `conv pull`; всё через раннер репозитория.
Имя региона обязательно и стабильно: перенос содержимого при обновлении Отдельного механизма отчёта о расхождении нет: обновление перезаписывает
идёт по именам, и переименование осиротит содержимое во всех копиях. файлы в рабочем дереве, а что именно изменилось, показывает `git diff` до
коммита. Поэтому в шапке копии хранится только `origin:` — отпечатков
канона и дат синхронизации в ней нет, историю держит git.
Правка выше маркера означает одно из двух: улучшение, которое переносят в
канон, или документ, переставший быть копией, — тогда `origin:` из шапки
убирают.
## Как проверить границу темы
Готовая тема проходится по шести вопросам; на каждый отвечает своё правило:
- на какой вопрос отвечает правило — и тот ли это вопрос, что у темы
(META-33);
- нужна ли тема правдоподобному потребителю целиком (META-34);
- слой сужает базу или отменяет её (META-35);
- зависит ли норма от вида приложения и назван ли он (META-36);
- названа ли тема решением и адресатом, а не ролью части проекта (META-37);
- исполнима ли норма, если соседних тем в репозитории нет (META-20).
Расхождение на любом из них означает, что граница проходит не там, где
нарисована: тема собрана вокруг вещества, склеила два решения или молча
предполагает вид приложения. Чинится это разрезом темы или областью
действия, а не смягчением нормы.
## Правила ## Правила
@@ -58,18 +104,177 @@ prefix: META
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор. **ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
**Почему.** Подписка — это набор лежащих в репозитории файлов, и берут файл **ПОЧЕМУ.** Подписка перечисляется по темам, и тему берут целиком. Файл,
целиком. Файл, собравший две темы, вынуждает репозиторий взять правила, собравший две темы, вынуждает репозиторий взять правила, которые ему не
которые ему не нужны, и получать шум в `diff` по чужой половине. Разрезать нужны, и вычитывать чужую половину при каждом обновлении. Разрезать позже
позже дорого: путь файлачасть адреса правила, и после разреза внешние дорого: перенос правила в другой файл — это новый префикс и новая
ссылки указывают не туда. нумерация, поэтому после разреза все внешние ссылки обходят руками.
### META-33. Правило стоит в теме, чей вопрос оно решает
**ДОЛЖЕН.** Тема правила определяется вердиктом, который правило выносит, а
не веществом, о котором оно говорит.
**ПОЧЕМУ.** Одно и то же вещество — время, идентификатор, конфигурация —
проходит через несколько решений сразу, и тема, собранная вокруг вещества,
склеивает чужие решения: «в каком виде хранить в базе», «что писать в лог»,
«что отдавать наружу» попадают в один файл на том основании, что все три
говорят о моментах. Подписка после этого промахивается в обе стороны:
репозиторий без базы получает правила о колонках, а репозиторий с базой, не
подписанный на время, правил о своих колонках не получает — хотя они про его
схему. Отличить одно от другого дёшево: вопрос темы выписывается одной
фразой, и норма читается как ответ на него; ответ на чужой вопрос означает,
что правило лежит не в своей теме.
### META-34. Тема нужна потребителю целиком
**СЛЕДУЕТ.** Тема нарезается так, чтобы правдоподобному потребителю
требовалась вся она, а не часть.
**ПОЧЕМУ.** Взять половину темы нечем: подписка перечисляется темами, и
сборщик кладёт файл целиком. Потребитель, которому нужна треть правил,
платит за остальные две трети вычиткой при каждом обновлении и пачкой
отступлений — а пачка отступлений неотличима от небрежности и обесценивает
список, по которому считают реальное соблюдение (META-14). Линия разреза
видна заранее: если два правдоподобных потребителя хотят непересекающиеся
части одной темы, между этими частями и проходит граница. Ступень ниже
высшей потому, что «правдоподобный потребитель» — суждение: двое разойдутся
в том, бывает ли такой репозиторий вообще.
### META-35. Слой сужает базу, но не отменяет её
**НЕ ДОЛЖЕН.** Правило языкового или стекового слоя не требует
противоположного норме арх-слоя своей темы и не снимает её требование.
**ПОЧЕМУ.** Слои темы приезжают в копию одним файлом, секция за секцией, и
исполняются подряд: база и отменяющее её уточнение стоят рядом без указания,
какое из них главнее, — читатель выбирает сам, и вердикт перестаёт быть
воспроизводимым (META-6). Отсюда же тест на границу: если ради нового случая
базу приходится отменять, это не слой, а другая тема — общим у них осталось
слово, а не решение. Сужение слоем остаётся: уточнить, ограничить, назвать
инструмент, разобрать случай, который база предусмотрела.
### META-36. Вид приложения называется, если норма от него зависит
**ДОЛЖЕН.** Норма, верная не для всякого приложения, сопровождается областью
действия, называющей вид приложения, для которого она написана.
**ПОЧЕМУ.** Вид приложения — веб-сервис, программа командной строки, набор
плейбуков, библиотека — меняет вердикт там, где язык и инструмент его не
меняют: лог сервиса читают через месяц запросом, вывод команды — сейчас и
глазами, поэтому уровень записи у них выбирается по-разному. Осями это
измерение не выражено: они отвечают на вопрос, от чего правило умирает, а не
к чему оно применяется, — и единственное место, где вид может быть назван,
область действия. Не названный, он остаётся молчаливым допущением автора:
потребитель другого вида не отличает «правило написано не про меня» от «мы
его нарушаем» и записывает второе, хотя чинится первое — условие
применимости в каноне (META-15.2).
### META-37. Имя темы называет решение и адресата, а не место в архитектуре
**СЛЕДУЕТ.** Именем темы служит решение вместе с тем, кому оно адресовано, а
не роль части конкретного проекта.
**ПОЧЕМУ.** Имя темы вечно и не переиспользуется (META-29): оно стоит в
`origin:` каждой копии, в подписках, в чужих ссылках. Роль же принадлежит
сегодняшнему устройству одного проекта — «фронтенд», который через три года
рендерится на сервере, называется по-прежнему, а означает другое, и заметить
расхождение нечем: имя ни на что не ссылается, кроме привычки. Пара имён вида
`logging-backend` и `logging-frontend` вдобавок навязывает чтение «две
разновидности одного», хотя по границе это две темы: серверную запись читают
постфактум инструментом, клиентскую — разработчик в консоли или сборщик ошибок
на той стороне сети, и общего у них остаётся три правила из сорока. Названные
по адресату — `logging` и `client-logging` — они и читаются как разные.
Ступень ниже высшей потому, что «решение против роли» — суждение о слове: на
границе двое разойдутся.
### META-38. Ось слоя объявляется в шапке файла
**ДОЛЖЕН.** Принадлежность слоя оси объявляется в шапке ключами `lang:` и
`stack:`, а не выводится из пути файла; отсутствие обоих ключей означает
базовый слой темы.
**ПОЧЕМУ.** Ось, выведенная из пути, ломается тем же способом, что и тема,
выведенная из имени файла (META-28), только тише: переезд файла между
директориями не меняет ни одного идентификатора, но меняет состав копии у
каждого потребителя — слой начинает выбираться при другом языке или всегда.
Сверить это не с чем, потому что путь ничего не утверждает, а объявления нет.
Объявление вдобавок выражает то, чего дерево директорий не выражает: слой,
осмысленный только при совпадении языка и инструмента сразу; и набор, у
которого осей нет вовсе, перестаёт требовать директорий-заглушек. Дерево при
этом остаётся — но тем же, чем уже является `extends:`, документацией связи
для человека.
### META-28. Тема объявляется в шапке файла и стоит в манифесте набора
**ДОЛЖЕН.** Шапка несёт ключ `topic:` с именем темы, и это имя стоит в
манифесте набора.
**ПОЧЕМУ.** Тема — единица подписки и единица сборки: потребитель перечисляет
темы в своём манифесте, а сборщик складывает в один файл все слои темы. Пока
имя выводится из имени файла, у сборщика нет способа узнать, что два слоя,
названные по-разному, — один документ; переименование файла при этом молча
заводит новую тему, подписка перестаёт находить прежнюю, а ссылки «конвенция
`logging`» в копиях остаются висеть. Объявленное имя вдобавок сверяется с
манифестом — выведенное сверять не с чем.
### META-29. Имя темы не переиспользуется
**НЕ ДОЛЖЕН.** Снятое или переименованное имя темы другой теме не выдаётся:
оно уходит в раздел выбывших манифеста с причиной и датой.
**ПОЧЕМУ.** Имя темы живёт в чужих репозиториях — в шапке `origin:` каждой
копии, в подписке потребителя, в тексте ссылок. Выданное второй теме, оно
начинает указывать на другой набор правил, и обнаруживается это не на сборке,
а по содержанию: файл обновится штатно, изменится текст. Та же дисциплина и по
той же причине действует для префиксов правил.
### META-30. Правка словаря или формы правила доходит до документа для читателя
**ДОЛЖЕН.** Изменение ключевых слов, их значений или состава частей правила
вносится и в короткое описание языка, которое едет в копию.
**ПОЧЕМУ.** Полное описание языка остаётся у автора набора, а по правилам код
проверяет читатель копии — человек или агент в чужом репозитории, у которого
из двух документов есть только короткий. Разошедшись, он начинает толковать
слова по прежней версии: ДОПУСКАЕТСЯ читается как бытовое «можно», отступление
от ДОЛЖЕН перестаёт требовать записи — то есть отказывает ровно то, ради чего
слова вводились, и молча. Проверить расхождение дёшево: словари в двух
документах либо совпадают, либо нет.
### META-31. Нумерация правил в файле сплошная
**ДОЛЖЕН.** Номера идут от единицы до наибольшего без пропусков: снятое
правило остаётся на месте заглушкой с меткой СНЯТО, а не исчезает.
**ПОЧЕМУ.** Дыра в нумерации неотличима от опечатки в номере и от правила,
которое забыли дописать, — проверка, увидев пропуск, не может сказать, ошибка
это или норма, поэтому либо молчит всегда, либо краснеет на живом файле.
Заглушка отвечает на тот же вопрос текстом: номер занят, правило снято
тогда-то и по такой-то причине. Переиспользовать номер по-прежнему нельзя —
ссылка из чужого репозитория обязана указывать на то же утверждение, — но и
отдельный
реестр снятых номеров не нужен: он был бы вторым источником правды рядом с
файлом, который и так всё сказал.
### META-32. Ссылка ведёт на правило, которое существует
**НЕ ДОЛЖЕН.** Идентификатор в тексте не указывает на правило, которого в
наборе нет.
**ПОЧЕМУ.** Неразрешимая ссылка означает одно из двух: опечатку в номере или
след переноса правила в другой файл. Читатель — тем более в чужом
репозитории — не различит эти случаи и решит, что правила больше нет, хотя оно
могло переехать. С заглушками (META-31) проверка становится однозначной:
идентификатор либо ведёт к правилу, либо к объяснению, почему его сняли, а
третьего исхода нет — и любой неразрешённый идентификатор точно ошибка.
### META-2. Конвенция заводится, когда решение принимается третий раз ### META-2. Конвенция заводится, когда решение принимается третий раз
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и **СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
каждый раз чуть по-другому. каждый раз чуть по-другому.
**Почему.** По одному-двум случаям не видно, что в решении повторяется, а **ПОЧЕМУ.** По одному-двум случаям не видно, что в решении повторяется, а
что было частностью места: правило, выведенное из первого случая, кодирует что было частностью места: правило, выведенное из первого случая, кодирует
частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
@@ -77,11 +282,11 @@ prefix: META
### META-3. Новая конвенция пишется там, где заболело ### META-3. Новая конвенция пишется там, где заболело
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера **СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера»
удаление прозы» делаются в репозитории, где случилась находка; в канон делаются в репозитории, где случилась находка; в канон продвигается общая
продвигается общая часть. часть.
**Почему.** Правило, написанное сразу в общем виде, не проверено ни одним **ПОЧЕМУ.** Правило, написанное сразу в общем виде, не проверено ни одним
применением, и условие применимости у него придумано, а не найдено, — применением, и условие применимости у него придумано, а не найдено, —
платят за это все потребители сразу. Формулировка, обкатанная на одном платят за это все потребители сразу. Формулировка, обкатанная на одном
репозитории, приезжает в канон уже с известной границей. репозитории, приезжает в канон уже с известной границей.
@@ -91,7 +296,7 @@ prefix: META
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без **НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
описаний того, как сейчас устроен конкретный репозиторий. описаний того, как сейчас устроен конкретный репозиторий.
**Почему.** Такое утверждение устаревает молча и подменяет норму описанием: **ПОЧЕМУ.** Такое утверждение устаревает молча и подменяет норму описанием:
читатель перестаёт понимать, что от него требуется, а что просто читатель перестаёт понимать, что от него требуется, а что просто
констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
@@ -100,12 +305,12 @@ prefix: META
### META-20. Норма самодостаточна, наружу смотрит только обоснование ### META-20. Норма самодостаточна, наружу смотрит только обоснование
**ДОЛЖЕН.** Норму правила можно исполнить, имея один этот файл. Ссылка на **ДОЛЖЕН.** Норму правила можно исполнить, имея один этот файл. Ссылка на
правило чужой темы допустима в «Почему», в «Связано» и в разграничении правило чужой темы допустима в обосновании, в «Связано» и в разграничении
области действия — но не в самой норме. Если норме нужен концепт соседней области действия — но не в самой норме. Если норме нужен концепт соседней
темы, он коротко повторяется здесь, а сосед называется в «Почему» как темы, он коротко повторяется здесь, а сосед называется в обосновании как
источник решения. источник решения.
**Почему.** Репозиторий подписывается на произвольное подмножество **ПОЧЕМУ.** Репозиторий подписывается на произвольное подмножество
конвенций, и графа зависимостей у него нет по построению. Норма, которую конвенций, и графа зависимостей у него нет по построению. Норма, которую
нельзя исполнить без отсутствующего файла, делает такое подмножество нельзя исполнить без отсутствующего файла, делает такое подмножество
невалидным молча: читатель видит связный текст и не замечает, что часть невалидным молча: читатель видит связный текст и не замечает, что часть
@@ -117,15 +322,30 @@ prefix: META
### META-21. Ссылка ведёт на тему или на правило, но не на путь в каноне ### META-21. Ссылка ведёт на тему или на правило, но не на путь в каноне
**ДОЛЖЕН.** На соседнюю конвенцию ссылаются именем темы (конвенция **ДОЛЖЕН.** На соседнюю конвенцию ссылаются именем темы (конвенция
`logging`), на конкретное правило — идентификатором (`SLOG-27`), на другой `logging`), на конкретное правило — идентификатором (`SLOG-27`); путь файла
слой своей же темы — словами «базовый слой». Путь файла канона в тексте канона в тексте конвенции не употребляется.
конвенции не употребляется.
**Почему.** В репозитории конвенция лежит собранной: слои одной темы — это **ПОЧЕМУ.** В репозитории конвенция лежит собранной: слои одной темы — это
секции одного файла, и пути `lang/go/logging.md` там не существует. Ссылка секции одного файла, и пути `lang/go/logging.md` там не существует. Ссылка
на путь канона умирает при сборке, причём молча — текст остаётся связным. на путь канона умирает при сборке, причём молча — текст остаётся связным.
Имя темы и идентификатор правила переживают и сборку, и переезд файла между Имя темы и идентификатор правила переживают и сборку, и переезд файла между
осями. осями. Слой своей темы поэтому называют идентификатором его правила, а не
словами «базовый слой»: слова не проверяются и не ведут к утверждению.
### META-24. Слой ссылается на идентификаторы своего базового слоя
**ДОПУСКАЕТСЯ.** Правило языкового или стекового слоя называет идентификатор
правила арх-слоя своей темы прямо в норме.
**ПОЧЕМУ.** Подписываются темой, а не слоем: собранный файл начинается с
арх-слоя независимо от того, какие язык и стек выбраны, — базовое правило в
копии всегда рядом, и ссылка на него никуда не ведёт. Повтор его концепта
здесь заводил бы второй источник правды внутри одного документа: META-20
требует повторять концепт там, где соседнего файла может не быть, а базовый
слой отсутствовать не может. Остальные слои темы попадают в копию по
манифесту, и такой гарантии у них нет — отсюда узость разрешения. Записано
оно явно, потому что META-20 читают строже, чем он есть, и без этой строки
базу дублируют без нужды.
### META-5. Расхождение кода с правилом — отступление, а не повод переписать правило ### META-5. Расхождение кода с правилом — отступление, а не повод переписать правило
@@ -133,68 +353,90 @@ prefix: META
фактическую ошибку, внутреннее противоречие или условие применимости, фактическую ошибку, внутреннее противоречие или условие применимости,
которое не даёт ответа. которое не даёт ответа.
**Почему.** Правило, подогнанное под текущий код, перестаёт что-либо **ПОЧЕМУ.** Правило, подогнанное под текущий код, перестаёт что-либо
требовать — оно описывает то, что и так происходит, и первое же расхождение требовать — оно описывает то, что и так происходит, и первое же расхождение
переписывает его снова. Направление «конвенция → код» держится ровно тем, переписывает его снова. Направление «конвенция → код» держится ровно тем,
что факт не считается аргументом. что факт не считается аргументом.
### META-6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается ### META-6. Высшая модальность требует воспроизводимого вердикта
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает **ДОЛЖЕН.** Правило со ступенью ДОЛЖЕН или НЕ ДОЛЖЕН формулируется так, что
машинную проверку или переводится в СЛЕДУЕТ. двое проверяющих по одному его тексту выносят один и тот же вердикт.
**Почему.** Без проверки правило держится на внимании: нарушения копятся **ПОЧЕМУ.** Проверяют конвенцию в первую очередь агент и человек — они читают
молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ текст правила и по нему смотрят код. Проверка, стало быть, есть у каждого
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько правила с первого дня, и её инструмент — формулировка, а не скрипт. Отсюда
таких случаев обесценивает остальные ДОЛЖЕН в файле. цена невоспроизводимой нормы: вердикт зависит от того, кто читал, нарушения
всплывают выборочно, а отступление нечем записать — неизвестно, нарушено ли.
Для СЛЕДУЕТ это честно, там суждение и есть содержание правила; ДОЛЖЕН в
таком виде обещает то, чего не делает, и через несколько случаев обесценивает
остальные ДОЛЖЕН в файле.
Отсюда следствие: правило, машинная проверка которого невозможна в принципе Отсюда следствие: правило, вердикт которого зависит от суждения по построению
(вкус формулировки, выбор границы, суждение о ситуации), не может быть (вкус формулировки, выбор границы, уместность в конкретном месте), не может
ДОЛЖЕН — его модальность СЛЕДУЕТ по построению, а не по слабости. быть ДОЛЖЕН — его модальность СЛЕДУЕТ по природе нормы, а не по слабости.
### META-7. Факт механизации фиксируется в локальном регионе со ссылкой на правило ### META-25. Высшая модальность выбирается, только когда назван вред
**ДОЛЖЕН.** Регион `механизировано` называет идентификатор правила и **СЛЕДУЕТ.** Правило получает ДОЛЖЕН или НЕ ДОЛЖЕН, если в обосновании
сказано, что́ ломается при нарушении.
**ПОЧЕМУ.** Воспроизводимость вердикта — условие необходимое (META-6), но не
достаточное: воспроизводимо проверяемых мелочей больше, чем важных вещей, и
без второго условия единственным фильтром остаётся удобство проверки. Шкала
наполняется опрятностью, читатель перестаёт отличать «уронит прод» от
«неаккуратно» — и обесцениваются все ДОЛЖЕН в файле, ровно то, от чего META-6
защищает с другой стороны. Собственная ступень этого правила — СЛЕДУЕТ:
форма обоснования ничем не ограничена, поэтому «вред назван» вердикта не
даёт — один читатель увидит названный вред там, где другой увидит объяснение
мотива.
### META-27. Механизация правила желательна, но ступени не задаёт
**СЛЕДУЕТ.** Правило со ступенью ДОЛЖЕН получает машинную проверку, когда
такую проверку можно написать.
**ПОЧЕМУ.** Линтер не забывает, ничего не стоит на каждом прогоне и краснеет
до ревью, а не на нём: там, где проверка пишется, она дешевле самого
внимательного чтения, и путь «находка → конвенция → проверка» кончается ею.
Норму она при этом не заменяет и не отменяет (META-8). Условием ступени
механизация не является:
проверяющий по умолчанию — читатель правила (META-6), а если требовать
скрипт, весь канон стоит в СЛЕДУЕТ до того дня, когда скрипты появятся.
Ступень говорит о важности нормы, а не о состоянии инструментов.
### META-7. Факт механизации фиксируется в копии со ссылкой на правило
**ДОЛЖЕН.** Запись о механизации называет идентификатор правила и
конкретную проверку. конкретную проверку.
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не **ПОЧЕМУ.** Механизация — состояние конкретного репозитория, канон о ней не
знает, а без записи следующий автор либо заведёт вторую проверку того же, знает, а без записи следующий автор либо заведёт вторую проверку того же,
либо будет вычитывать глазами уже проверенное машиной. Без идентификатора либо будет вычитывать глазами уже проверенное машиной. Без идентификатора
читатель догадывается сам, к какому утверждению относится проверка, — и читатель догадывается сам, к какому утверждению относится проверка, — и
догадывается по-разному. догадывается по-разному.
### META-8. Формулировка не удаляется из канона, пока механизирована не у всех ### META-8. Норма из канона не удаляется, чем бы она ни проверялась
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя **НЕ ДОЛЖЕН.** Формулировка нормы остаётся в правиле независимо от того, кто и
машинной проверки нет. чем её проверяет.
**Почему.** У кого линтера нет, тот после удаления остаётся без правила **ПОЧЕМУ.** Проверяющий по умолчанию — читатель правила (META-6), и удаление
вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не нормы забирает у него ровно то, чем он проверяет: линтер сообщает, что
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» — нарушено, но не сообщает, что требуется. Условие «механизировано у всех»
значит чинить свой файл за чужой счёт. спасти не может: оно измеряется в день удаления, а подписчики появляются
после. Репозиторий, подключившийся через год, получил бы правило без нормы и
Списка подписчиков канон по построению не знает, поэтому факт «механизировано без проверки — заголовок с обоснованием и никакого способа узнать, что́ именно
у всех» устанавливается обходом репозиториев вручную — это часть работы по предписано, кроме git-истории канона, до которой он не дойдёт. Списка
удалению нормы, а не то, что можно проверить машиной (см. `LANGUAGE.md`, подписчиков у канона к тому же нет по построению, так что «у всех» ему всё
состояние МЕХАНИЗИРОВАНО). равно не проверить.
### META-9. Общая механизация разрешает удалить норму из канона
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
удаляется из канона одним `push`.
**Почему.** Формулировка, дублирующая работающую у всех проверку,
размазывает внимание: файл на несколько сотен строк заставляет человека и
агента добросовестно вычитывать тривиальное именование и не доходить до
формы решения. Явное разрешение нужно, чтобы META-8 не читался как запрет
удалять вообще.
### META-10. Обоснование не удаляется никогда ### META-10. Обоснование не удаляется никогда
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в **НЕ ДОЛЖЕН.** Блок ПОЧЕМУ остаётся и после того, как правило стало
линтер. проверяться линтером.
**Почему.** Линтер сообщает, что нарушено, но не сообщает, зачем правило **ПОЧЕМУ.** Линтер сообщает, что нарушено, но не сообщает, зачем правило
существует. Без обоснования не видно, когда причина отпала, — проверка существует. Без обоснования не видно, когда причина отпала, — проверка
продолжает работать по инерции, и возразить ей нечем, кроме как отключив. продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
@@ -204,7 +446,7 @@ prefix: META
называет, к чему применяется: к новым таблицам и миграциям, а не к называет, к чему применяется: к новым таблицам и миграциям, а не к
состоянию схемы. состоянию схемы.
**Почему.** Здесь не работает привычное «новое пишем правильно, старое **ПОЧЕМУ.** Здесь не работает привычное «новое пишем правильно, старое
переезжает по мере касания»: таблица не переезжает от того, что её переезжает по мере касания»: таблица не переезжает от того, что её
потрогали. Без явной рамки правило читается как требование к текущему потрогали. Без явной рамки правило читается как требование к текущему
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
@@ -215,7 +457,7 @@ prefix: META
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже **ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
существующей схеме. существующей схеме.
**Почему.** Проверка состояния краснеет на легаси с первого дня: её **ПОЧЕМУ.** Проверка состояния краснеет на легаси с первого дня: её
отключают или обвешивают вечным списком исключений — и она перестаёт ловить отключают или обвешивают вечным списком исключений — и она перестаёт ловить
новое, ради чего заводилась. Проверка границы оставляет старое в покое и новое, ради чего заводилась. Проверка границы оставляет старое в покое и
делает новую ошибку невозможной. делает новую ошибку невозможной.
@@ -225,22 +467,22 @@ prefix: META
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не **ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
как задачи на дочистку. как задачи на дочистку.
**Почему.** Список, записанный долгом, требует либо мигрировать живые данные **ПОЧЕМУ.** Список, записанный долгом, требует либо мигрировать живые данные
без выгоды, либо год за годом объяснять невыполненный план. Второе кончается без выгоды, либо год за годом объяснять невыполненный план. Второе кончается
тем, что список перестают вести, — и пропадает единственное место, где видно, тем, что список перестают вести, — и пропадает единственное место, где видно,
где именно правило не действует. где именно правило не действует.
### META-14. Отступления перечисляются поимённо, со ссылкой на правила ### META-14. Отступления перечисляются поимённо, со ссылкой на правила
**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в **ДОЛЖЕН.** В локальной части копии перечислены отступления, которые уже
коде, с идентификатором правила и причиной. есть в коде, с идентификатором правила и причиной.
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить **ПОЧЕМУ.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
это можно только чтением всего кода. Со ссылками отступления счётны: видно, это можно только чтением всего кода. Со ссылками отступления счётны: видно,
сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при сколько правил конвенции репозиторий реально не соблюдает. Пустой список при
этом почти всегда означает не отсутствие отступлений, а то, что их не искали. этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
### META-15. Запись в регионе отступлений разбирается по масштабу ### META-15. Запись об отступлении разбирается по масштабу
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано: **ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
@@ -250,27 +492,47 @@ prefix: META
| META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне | | META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
| META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет | | META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
**Почему.** Отступление описывает исключение, и по нему видно, какая часть **ПОЧЕМУ.** Отступление описывает исключение, и по нему видно, какая часть
правила нарушена. Запись «мы это правило вообще не применяем» такой правила нарушена. Запись «мы это правило вообще не применяем» такой
информации не несёт и маскирует одну из двух чинимых причин: неверную рамку информации не несёт и маскирует одну из двух чинимых причин: неверную рамку
правила в каноне, которую чинят один раз для всех, или лишнюю подписку, правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
где файл просто не нужен. Оставленная отступлением, она прячет обе. где файл просто не нужен. Оставленная отступлением, она прячет обе.
### META-17. Репо-специфичная часть «Связано» — в локальном регионе ### META-17. Ссылки на код репозитория стоят в локальной части, а не в «Связано»
**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в **ДОЛЖЕН.** Раздел «Связано» канона содержит только ссылки, верные у всех
общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в потребителей; ссылки на ADR, код и файлы конкретного репозитория — в
локальном регионе. локальной части копии.
**Почему.** Общий текст уезжает `push`-ем ко всем потребителям, и ссылка на **ПОЧЕМУ.** Текст канона приезжает ко всем потребителям, и ссылка на чужой
чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому файл у них битая с первого дня. Ниже маркера та же ссылка никого не
та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`. задевает и переживает обновление, потому что обновление её не трогает.
### META-22. Репозиторное в копии пишется ниже маркера локальной части
**ДОЛЖЕН.** Правки репозитория вносятся ниже маркера, а не в текст,
пришедший из канона.
**ПОЧЕМУ.** Обновление перезаписывает всё, что выше маркера, поэтому правка
там живёт до первого `pull`. Заметить пропажу можно, только вычитав
`git diff` целиком — а он в этот момент и без того полон изменений канона,
и своя строка теряется среди чужих.
### META-23. Документ, переставший быть копией, не носит `origin:`
**НЕ ДОЛЖЕН.** Файл, который развели с каноном намеренно, шапку `origin:`
не сохраняет.
**ПОЧЕМУ.** По `origin:` решается, какие файлы пересобирать из канона. Форк,
оставивший шапку, при первом же обновлении теряет ровно то, ради чего его
заводили. Происхождение такого документа остаётся в истории коммита, где оно
никого не вводит в заблуждение.
### META-18. README директории перечисляет конвенции с однострочным описанием ### META-18. README директории перечисляет конвенции с однострочным описанием
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он. **СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
**Почему.** Подписка — это набор лежащих файлов, и без описаний вопрос **ПОЧЕМУ.** Подписка — это набор лежащих файлов, и без описаний вопрос
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток «какая из них про мой случай» решается открыванием каждой. Ценой в десяток
файлов это означает, что не открывают ни одной. файлов это означает, что не открывают ни одной.
@@ -279,11 +541,32 @@ prefix: META
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его **ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
идентификатором; детали остаются в конвенции. идентификатором; детали остаются в конвенции.
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только **ПОЧЕМУ.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
если его туда отправили, — а безусловно он читает точку входа. Строка с если его туда отправили, — а безусловно он читает точку входа. Строка с
идентификатором служит и напоминанием, и адресом, по которому за идентификатором служит и напоминанием, и адресом, по которому за
подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух
текстов. текстов.
<!-- local:точки-входа --> ## Снятые правила
<!-- /local -->
Снятое правило остаётся здесь заглушкой: номер занят навсегда, ссылка на него
ведёт к объяснению, а нумерация в файле остаётся сплошной (META-31).
### META-9. Общая механизация разрешала удалить норму из канона
**СНЯТО 2026-07-26.** Удаление нормы оставляло подписчика, пришедшего позже,
без текста и без проверки, а условие «механизировано у всех» набору не
проверить: списка подписчиков у него нет. Взамен — META-8, запрет удалять
норму вообще.
### META-16. Имя файла — kebab-case
**СНЯТО 2026-07-26.** Вреда от нарушения нет, а значит нет и высшей
модальности (META-25): сборка идёт по имени темы из шапки, а не по имени
файла. Осталось прозой в разделе «Оформление».
### META-26. Запрет слов обязательства в обосновании
**СНЯТО 2026-07-26.** Правило о заглавных уже делает строчное «обязан»
ненормативным, поэтому запрет ничего не добавлял, а форму обоснования рамками
не ограничивают.
+558 -136
View File
@@ -1,26 +1,105 @@
---
version: 1
---
# Язык конвенций # Язык конвенций
Как записываются правила в этом каноне. Документ описывает форму, а не Формальный язык, на котором записаны правила этого канона: что считается
содержание: что такое правило, чем оно отличается от прозы вокруг и как на правилом, чем оно отличается от прозы вокруг, какими словами задаётся
него сослаться. обязательность и как на правило сослаться извне.
Форма подсмотрена у OpenSpec, но взято оттуда не всё — см. «Чего мы не Версия языка — **1**. Номер называется в каждой конвенции: словарь может
берём». пополниться, и текст, написанный по предыдущей версии, должен читаться по
той, по которой написан.
## Зачем формализовать Документ адресован автору набора и в репозиторий-потребитель не едет. К
читателю копии едет короткое `READING.md`: словарь со значениями, форма
правила и её граница, ссылки, локальная часть — без разделов о ведении набора.
Словарь в двух документах обязан совпадать (META-30), и это единственное
место, где между ними возможен дрейф.
Не ради строгости. Три конкретные вещи, которые без адресуемых правил не Описание языка ни на один набор конвенций не опирается, поэтому все примеры
работают: здесь — вымышленные правила с префиксами на `X` (`XKEY`, `XMIG`, `XLOG`).
Такие префиксы канон не занимает никогда, в манифесте набора их нет — значит
пример не спутать с настоящим правилом, а перенумерация конвенций описание
языка не задевает.
- **Механизация.** Регион `механизировано` должен говорить «правило ## Опора на стандарты
`MIGR-4` проверяет `archrules`», а не «`AUTOINCREMENT` в новых миграциях —
`archrules`»: во втором случае читатель сам догадывается, к какому Язык не выводится из вкуса автора. Каждое решение о форме взято из
утверждению это относится, и догадывается по-разному. документа, где эта задача уже решена и обкатана, и отклонения от источника
названы явно.
| Источник | Что взято | Что отклонено |
|---|---|---|
| **BCP 14** (RFC 2119 + RFC 8174) | шкала модальности; правило «нормативно только заглавное»; сдержанность в высшей модальности; англоязычный словарь как готовый | синонимы `REQUIRED`, `RECOMMENDED`, `OPTIONAL`; `SHALL` как форма требования |
| **ISO/IEC Directives, Part 2** | разделение «требование — рекомендация — разрешение — возможность»; запрет модальных глаголов в утверждениях о факте | списки равнозначных словесных форм («is to», «is required to», «only … is permitted») |
| **ISO/IEC/IEEE 29148** | характеристики хорошего требования — единичность и проверяемость; обоснование и метод верификации как отдельные атрибуты требования | остальной аппарат требований: приоритеты, источники, матрицы трассируемости |
| **DMN** | таблица решений с объявленной политикой совпадения | исполняемая семантика и всё, что предполагает движок решений |
| **EARS** | паттерн «нежелательное поведение» — в виде таблицы | шаблоны как форма записи правила: `WHEN`, `WHILE`, `WHERE` |
| **OpenSpec** | четырёхчастная форма правила; адресуемость правила идентификатором; форма «условие → следствие» для стыка правил | `SHALL`; `GIVEN/WHEN/THEN` как общая форма записи |
Три отклонения стоят объяснения, потому что выглядят как произвол.
**Синонимов нет.** BCP 14 держит `REQUIRED` рядом с `MUST` и `OPTIONAL`
рядом с `MAY` ради читаемости английской прозы. Одна форма записи на ступень
означает, что проверка «модальное слово употреблено вне правила» становится
перечислением, а не разбором синонимических рядов.
**`SHALL` не используется ни в каком словаре этого языка.** Слово занято
спецификациями (OpenSpec), и общая с ними форма стирала бы границу между
конвенцией и описанием поведения системы: `SHALL` в конвенции читался бы как
контракт, которого конвенция не даёт. Для англоязычного словаря это означает
выбор в пользу `MUST` из BCP 14, а не `shall` из ISO/IEC Directives.
**`GIVEN/WHEN/THEN` не берётся как общая форма.** У спецификации субъект —
система, и её поведение разворачивается во времени: состояние, событие,
исход. У конвенции субъект — автор кода, и разворачивать нечего: есть
ситуация выбора и вердикт. Это таблица, а не траектория. Тем же рассуждением
отклонены шаблоны EARS как форма записи правила, а взято из EARS другое —
сообщённое снижение числа дефектов после введения шаблонов. Вывод, что дело в
самой обязательности формы, а не в её конкретном виде, наш; он ниже, среди
усилений.
Исключение — стык правил, где субъект действительно система: там форма
«условие → следствие» берётся сознательно, вместе со служебными словами под
неё. Это единственное место, и оно описано в «Таблицах решений».
## Где источник усилен
Три решения идут дальше источника, и это наши решения, а не его требования.
Названы они отдельно, чтобы довод не подменялся ссылкой: спорить с ними нужно
по существу, а не со стандартом.
- **Обоснование обязательно.** В 29148 rationale — из списка рекомендуемых
атрибутов требования; обязательный костяк там другой, это характеристики
самого требования. Здесь правило без блока ПОЧЕМУ не принимается, потому что
конвенция живёт годами и переживает автора: норма без причины через год либо
отменяется первым возражением, либо соблюдается там, где вредит.
- **Полнота таблицы решений.** DMN даёт политику совпадения как именованный
атрибут, а полноты не требует: индикатор полноты был в первой версии
спецификации и из последующих убран, полноту проверяют валидаторы
инструментов. Здесь она требуется, потому что таблицу и заводят ради
видимости пропуска: неперечисленный случай в прозе не виден, а пустая
клетка видна.
- **Вывод про обязательность шаблона.** В EARS сообщается о снижении числа
дефектов в требованиях после введения шаблонов. Вывод, что выигрыш даёт сама
обязательность формы, а не её конкретный вид, — наш: он объясняет, почему мы
берём из EARS результат, но не берём сами шаблоны.
## Что даёт формализация
Адресуемое правило — не украшение формы, а условие работы трёх механизмов:
- **Механизация.** Запись о ней должна говорить «правило `XMIG-4` проверяет
`archrules`», а не «`AUTOINCREMENT` в новых миграциях — `archrules`»: во
втором случае читатель сам догадывается, к какому утверждению это
относится, и догадывается по-разному.
- **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно - **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно
«не так». Со ссылкой на правило отступления становятся счётными: видно, «не так». Со ссылкой на правило отступления становятся счётными: видно,
сколько правил конвенции репозиторий реально не соблюдает. сколько правил конвенции репозиторий реально не соблюдает.
- **Промоут находки.** Путь «находка → конвенция → правило линтера - **Промоут находки.** Путь «находка → конвенция → правило линтера» требует
удаление прозы» требует ручки, за которую берут конкретное правило. ручки, за которую берут конкретное правило.
Общий знаменатель — **идентификатор**. Модальные слова и таблицы полезны, Общий знаменатель — **идентификатор**. Модальные слова и таблицы полезны,
но вторичны. но вторичны.
@@ -28,82 +107,402 @@
## Единица — правило ## Единица — правило
```markdown ```markdown
### KEYS-5. Разбор внешнего идентификатора на границе ### XKEY-5. Разбор внешнего идентификатора на границе
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса **ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
к базе. к базе.
**Почему.** Разбор валидирует формат и нормализует регистр. Сравнение строк **ПОЧЕМУ.** Разбор валидирует формат и нормализует регистр. Сравнение строк
в базе побайтовое, поэтому без нормализации запрос молча не находит в базе побайтовое, поэтому без нормализации запрос молча не находит
существующую запись — отладка такого случая стоит дороже, чем сам разбор. существующую запись — отладка такого случая стоит дороже, чем сам разбор.
``` ```
Четыре обязательные части: **идентификатор**, **заголовок**, **модальность Четыре обязательные части: **идентификатор**, **заголовок**, **модальность
с нормой**, **почему**. Норма — одна фраза; если в неё не влезает, это два с нормой**, **обоснование под меткой ПОЧЕМУ**. Пятый блок, ПРИМЕРЫ,
правила. необязателен — о нём ниже. Норма — одна фраза; если в неё не влезает, это два
правила. Требование единичности взято из ISO/IEC/IEEE 29148: составная норма
не проверяема целиком, и нарушение одной её половины нечем адресовать.
## Правило без «почему» не принимается Метки правила — модальное слово, ПОЧЕМУ, ПРИМЕРЫ — пишутся заглавными и
принадлежат словарю набора: скелет правила читается одинаково в любом языке,
на который канон переведён.
Это жёсткое требование к форме, а не пожелание. Причины: **Правило кончается перед следующим заголовком.** Область правила — от его
заголовка до следующего заголовка любого уровня. Внутри области текст
принадлежит последнему открытому блоку: метка блок открывает, и блок длится
до следующей метки или до конца области.
- **«Почему» — единственный способ понять, когда правило перестало ```markdown
действовать.** Норма стареет молча; обоснование стареет заметно. Когда ### XKEY-3. Заголовок правила
причина отпала, видно, что правило пора убрать, а не соблюдать по
инерции. **ДОЛЖЕН.** Норма одной фразой.
| № | ситуация | вердикт | ← блок нормы: таблица уточняет её
**ПОЧЕМУ.** Причина.
Продолжение причины, пример, ← блок обоснования продолжается
ссылка на внешнюю практику.
### XKEY-4. Следующее правило ← здесь область кончилась
```
Отсюда три следствия:
- **Хвост после ПОЧЕМУ — обоснование** до следующей метки или до конца
области, а не безымянная часть правила и не проза вокруг. Требований в нём
не живёт: то, что подлежит исполнению, стоит в блоке нормы, где у него есть
модальность и адрес. Требование, оставленное
в хвосте, требованием не является — сослаться на него нельзя и отступление
от него записать нельзя.
- **Таблица и список после модальной метки — часть нормы.** Правило,
классифицирующее ситуации, ровно так и записывается («Таблицы решений»), а
вердикт из такой таблицы адресуется номером строки.
- **Проза — это то, что лежит вне областей правил.** Тем самым проверка
«заглавных модальных слов вне правил нет» становится реализуемой: границу
считает разметка, а не читательское суждение о том, где правило кончилось.
Заглавное модальное слово внутри области правила законно, когда это
упоминание ступени в обосновании («для СЛЕДУЕТ это честно»). Метку от
упоминания отличает положение: метка стоит первой в своём абзаце, полужирным
и с точкой.
## Примеры к правилу
Пятый блок правила — необязательный, под меткой ПРИМЕРЫ. В нём код,
показывающий норму в деле, обычно парой «плохо → хорошо». Стоит он после
обоснования: сначала требование, потом причина, потом иллюстрация.
````markdown
### XKEY-5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос.
**ПОЧЕМУ.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой.
**ПРИМЕРЫ.**
Плохо — строка уходит в запрос как пришла:
```go
row := db.QueryRow("select … where id = ?", r.PathValue("id"))
```
Хорошо — разбор на границе, запроса при неудаче нет:
```go
id, err := ident.Parse(r.PathValue("id"))
if err != nil {
return notFound(w)
}
row := db.QueryRow("select … where id = ?", id)
```
````
**Пример иллюстрирует норму, а не задаёт её.** Три следствия, ради которых
это сказано:
- **требований в блоке нет.** Всё, что подлежит исполнению, стоит в блоке
нормы; деталь примера — имя переменной, конкретная функция, форма ответа —
требованием не становится. Разошёлся пример с нормой — действует норма, а
пример правят;
- **это не готовый сниппет.** Код в примере сокращён до того, что показывает
правило: обработка ошибок, контекст, импорты в нём условны, и копировать его
дословно не нужно;
- **пример стареет быстрее нормы.** Он привязан к сегодняшнему API, поэтому
расхождение примера с текущим кодом — повод поправить пример, а не отменять
правило.
Блок необязателен: он окупается там, где норму словами описать дороже, чем
показать, — форма вызова, структура записи в логе, раскладка файла. У правила
про выбор границы или про уровень лога иллюстрировать нечего.
## Обоснование обязательно
Правило без блока ПОЧЕМУ не принимается. Это требование к форме, а не
пожелание. В 29148 обоснование — отдельный атрибут требования, но из
рекомендуемых; здесь оно обязательно, и вот почему:
- **Обоснование — единственный способ увидеть, что правило устарело.**
Норма стареет молча; причина стареет заметно. Когда причина отпала, видно,
что правило пора убрать, а не соблюдать по инерции.
- **Правило без обоснования не переживает спор.** Через год ни автор, ни - **Правило без обоснования не переживает спор.** Через год ни автор, ни
агент не восстановят мотив, и правило будет либо отменено первым же агент не восстановят мотив, и правило будет либо отменено первым же
возражением, либо соблюдено там, где вредит. возражением, либо соблюдено там, где вредит.
- **Формулировка «почему» — проверка на то, что это вообще правило.** Если - **Формулировка обоснования — проверка на то, что это вообще правило.**
причина не формулируется, перед нами привычка или вкусовщина; ей место в Если причина не формулируется, перед нами привычка или вкусовщина; ей
черновиках, а не в конвенции. место в черновиках, а не в конвенции.
«Почему» отвечает на «что сломается, если сделать иначе», а не пересказывает Обоснование отвечает на «что сломается, если сделать иначе», а не
норму другими словами. «Потому что так принято» — не обоснование. пересказывает норму другими словами. «Потому что так принято» — не
обоснование.
Форма обоснования при этом ничем не ограничена: рамки здесь только
смысловые. Абзац может быть длинным, вести рассуждение, приводить пример,
ссылаться на стандарты, внешние практики и чужие проекты — на устройство
OpenTelemetry, на умолчания библиотек логирования, на процедуру миграции из
документации СУБД. Запрещённых слов и обязательной
структуры у обоснования нет, и заводить их не нужно: обязательность несёт
норма, а обоснование её объясняет — путаницу между этими двумя ролями
исключает правило о заглавных.
## Модальные слова ## Модальные слова
Пишутся капсом — это ключевые слова, а не обычный текст. Инвариант языка — **шкала**: пять ступеней в четырёх категориях ISO/IEC
Directives, Part 2, по одной форме записи на ступень, заглавными. Какими
словами ступени названы — параметр естественного языка набора, а не часть
языка конвенций. Этот канон написан по-русски и несёт русский словарь.
| Слово | Значение | Отступление | Пишутся заглавными — это ключевые слова, а не обычный текст.
| Слово | Категория | Значение | Отступление |
|---|---|---|---|
| **ДОЛЖЕН** | требование | нарушение считается ошибкой | только с записью в отступления |
| **НЕ ДОЛЖЕН** | требование | запрет | то же |
| **СЛЕДУЕТ** | рекомендация | сильная рекомендация; новый код пишем так | допустимо, причину записываем |
| **НЕ СЛЕДУЕТ** | рекомендация | обратное к СЛЕДУЕТ | то же |
| **ДОПУСКАЕТСЯ** | разрешение | выбор за автором кода; возражение на ревью не принимается | не требуется — правило ничего не запрещает |
**Нормативно только заглавное написание.** Это правило RFC 8174, и оно
здесь по той же причине, по которой понадобилось там: без него каждое
строчное «должен» во вводной прозе становится предметом спора о том, норма
это или речь. Строчное слово нормой не является никогда, поэтому проза
свободна, а проверка «модальное слово вне правила» сводится к поиску
заглавных форм.
**Четвёртая категория ISO — возможность — ключевого слова не имеет.**
Утверждения о том, что бывает и что технически осуществимо, пишутся обычной
прозой и модальных слов не несут. Модальное слово в таком утверждении
превращает описание в норму, которую никто не собирался вводить.
**ДОПУСКАЕТСЯ адресовано рецензенту.** В BCP 14 у `MAY` есть вторая
половина, которую обычно не замечают: сторона, не выбравшая опцию, обязана
работать с той, что выбрала. В конвенции этому соответствует запрет
возражать: выбор, помеченный ДОПУСКАЕТСЯ, на ревью не обсуждается. Без этой
половины слово было бы удобством читателя, а не нормой, и не работало бы в
единственной точке, где у конвенции есть принуждение.
**ДОЛЖЕН требует двух условий сразу:**
1. нарушение причиняет названный вред, а не расходится со вкусом — META-25,
он же критерий BCP 14, где высшая модальность резервируется под то, что
действительно ломается, и не употребляется для навязывания метода;
2. вердикт о нарушении воспроизводим — META-6: по тексту правила двое
проверяющих приходят к одному ответу, иначе обязательность держится на
том, кто читал.
Не выполнено первое — правилу место в СЛЕДУЕТ или нигде. Не выполнено
второе — в СЛЕДУЕТ. Воспроизводимость сама по себе не повышает правило до
ДОЛЖЕН: проверяемых мелочей больше, чем важных вещей, и безразборное
повышение обесценивает шкалу быстрее, чем её отсутствие.
Модальность живёт на **правиле**, а не на файле. Файловый статус
(`status: рекомендуемая` / `обязательная` в шапке) не используется: он
неизбежно врёт, потому что один файл смешивает жёсткие требования с
советами. В шапке остаются только `topic`, `prefix` и `extends`.
## Словарь другого языка
Словарь набора — три перечня, и требования к ним одни и те же.
**Шкала обязательности.** Для английского готовый словарь даёт BCP 14; для
любого другого языка слова берут из перевода стандарта, если он есть, или
переводят сами. Шкала и семантика ступеней при этом не меняются — меняется
только запись.
| Ступень | Русский | Английский (BCP 14) |
|---|---|---| |---|---|---|
| **ДОЛЖЕН** | нарушение считается ошибкой | только с записью в регион `отступления` | | требование | ДОЛЖЕН | MUST |
| **НЕ ДОЛЖЕН** | запрет | то же | | запрет | НЕ ДОЛЖЕН | MUST NOT |
| **СЛЕДУЕТ** | сильная рекомендация; новый код пишем так | допустимо, причину записываем | | рекомендация | СЛЕДУЕТ | SHOULD |
| **НЕ СЛЕДУЕТ** | обратное к СЛЕДУЕТ | то же | | рекомендация против | НЕ СЛЕДУЕТ | SHOULD NOT |
| **ДОПУСКАЕТСЯ** | явное разрешение | не требуется — правило ничего не запрещает | | разрешение | ДОПУСКАЕТСЯ | MAY |
**ДОПУСКАЕТСЯ** нужно не для симметрии: оно снимает вопрос «а так можно?» **Метки.** Обязательности не задают, а размечают: ПОЧЕМУ — обоснование,
там, где соседнее правило звучит строго и его легко перечитать шире, чем ПРИМЕРЫ — иллюстрации к норме, МЕХАНИЗИРОВАНО — запись о проверке в копии,
задумано. СНЯТО — заглушку на месте убранного правила. Стандартом не даются ни в одном
языке: в BCP 14 таких понятий нет, слова подбираются под язык так же, как
остальные.
Модальность живёт на **правиле**, а не на файле. Прежний файловый статус | Метка | Русский | Английский |
(`status: рекомендуемая` / `обязательная` в шапке) отменён: он неизбежно |---|---|---|
врал, потому что один файл смешивает жёсткие требования с советами. В шапке | обоснование | ПОЧЕМУ | WHY |
остаются только `prefix`, `extends` и служебные ключи копии. | иллюстрации | ПРИМЕРЫ | EXAMPLES |
| способ проверки | МЕХАНИЗИРОВАНО | MECHANIZED |
| снятое правило | СНЯТО | RETIRED |
Мы **не используем SHALL и прочие английские ключевые слова**. Они заняты **Служебные слова сценарного блока** — `КОГДА`, `ТОГДА`, `И`, `ИЛИ`; таблица
спецификациями (OpenSpec), и общий словарь стирал бы границу «конвенция — и объяснение в разделе «Таблицы решений».
не capability». Разный словарь эту границу держит бесплатно.
Что требуется от любого словаря:
- **одна форма на ступень и на метку.** Синонимы отклонены не из аскетизма:
проверка «модальное слово вне правила» перечисляет формы, и синонимический
ряд превращает перечисление в разбор.
- **слово заглавными не встречается в обычной прозе этого языка.** Иначе
правило «нормативно только заглавное» перестаёт спасать: проверка ловит
оформление, а не модальность.
- **модальные слова и метки перечислены в строке о версии языка.** Читателю
копии они известны из самого файла, без обращения к этому документу, —
иначе конвенция в чужом репозитории теряет ключ к собственному тексту.
Служебные слова сценария в строку не входят: структура блока читается из
самого блока, и в файле без стыков правил их нет вовсе.
- **словарь один на канон.** Два словаря параллельно дают две формы записи
одного требования и удваивают каждую проверку; выбор языка — свойство
набора, а не отдельного файла.
## Ссылка на язык из конвенции
Каждая конвенция называет язык одной строкой во вводной прозе:
> Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
> ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
> конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Слова в строке — из словаря того языка, на котором написан набор. Для
англоязычного набора та же строка выглядит так:
> The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY,
> EXAMPLES, MECHANIZED and RETIRED are to be interpreted as described in the
> conventions language, version 1, and only when written in capitals.
Форма скопирована у BCP 14, где та же задача решается тем же способом:
спецификация не прикладывает к себе словарь и не указывает путь к нему, а
называет документ и версию. Пути в этой строке нет намеренно — конвенция
уезжает в чужой репозиторий, где путей канона не существует, а норму
исполнить всё равно можно: строка сама перечисляет ключевые слова набора и
сама несёт правило заглавных.
## Обязательность и способ проверки — разные атрибуты
Механизация не входит в шкалу модальности: она говорит не о том, насколько
правило обязательно, а о том, чем эта обязательность обеспечена. В 29148 это
два разных атрибута требования, и здесь тоже два.
**Проверяющий по умолчанию — читатель правила**, человек или агент. Канон
пишется прежде всего под агента: он читает конвенцию и по ней смотрит код,
то есть проверка есть у каждого правила с первого дня, и её инструмент —
формулировка нормы. Поэтому вторым условием ДОЛЖЕН стоит воспроизводимость
вердикта (META-6), а не наличие скрипта: ступень говорит о важности нормы и о
том, сколько внимания она получает при проверке, а не о состоянии
инструментов. Линтер сильнее чтения — он не забывает, ничего не стоит на
каждом прогоне и краснеет до ревью, — поэтому механизация желательна везде,
где проверка пишется (META-27), и остаётся концом пути «находка → конвенция →
проверка». Но обязательным условием высшей ступени она не является: иначе весь
канон стоял бы в СЛЕДУЕТ до появления скриптов, которых пока нет ни одного.
**Механизация нормы не заменяет и не сокращает.** Норма остаётся в правиле
навсегда — как и обоснование (META-8, META-10), — сколько бы проверок её ни
подпирало. Причин три:
- **линтер сообщает, что нарушено, но не сообщает, что требуется.** Без нормы
правило нечем исполнить и не с чем сверить вердикт проверки, а проверяющий
по умолчанию читает именно норму;
- **подписчики появляются позже.** Репозиторий, подключившийся через год,
получил бы правило без нормы и без линтера — ни текста, ни проверки;
- **«механизировано у всех» набору не проверить:** списка подписчиков у него
нет по построению.
**Отметка — свойство репозитория, а не набора.** Механизирована норма или нет,
зависит от того, чей это репозиторий, поэтому в тексте конвенции отметки нет:
её место — запись о механизации в локальной части копии, со ссылкой на
идентификатор правила (META-7).
```markdown
<!-- conv:local -->
XMIG-2, XMIG-4 — МЕХАНИЗИРОВАНО: `internal/archrules`, в новых миграциях.
```
Так у правила остаются оба атрибута сразу: обязательность — в норме, которая
приезжает из набора и одинакова у всех, способ проверки — в записи, которая
принадлежит репозиторию и у каждого своя.
## Таблицы решений
Часть правил **классифицирует ситуации**: какой уровень лога, какая
категория директории, что делать с невалидным вводом в зависимости от его
источника. Каноническая форма для них — таблица «ситуация → вердикт», строки
которой нумеруются как подпункты правила (`XLOG-8.1`).
Таблица плотнее прозы и не даёт пропустить ветку: пустая клетка видна, а
неупомянутый случай в абзаце — нет. От такой таблицы требуются два свойства —
первое названо в DMN, второе мы добавили сами («Где источник усилен»):
- **Политика совпадения.** По умолчанию строки взаимоисключающи: любой
ситуации соответствует ровно одна. Если это не так, таблица объявляет
порядок строкой над собой — «применяется первое совпадение». Молчание об
этом означает, что при двух подходящих строках читатель выбирает сам, и
два автора выберут по-разному.
- **Полнота.** Перечислены все случаи, попадающие в область действия. Если
возможен случай вне перечисленных, он назван отдельной строкой, а не
оставлен на догадку.
Сценарный блок остаётся точечным инструментом — для **стыка правил**, когда
два правила вместе дают неочевидный результат:
```
КОГДА зависимость недоступна И ретраи вызова исчерпаны
ТОГДА внешний вызов даёт запись ERROR,
И тик фонового цикла, упавший по той же причине, — запись WARN
```
Такой блок ставится после обоих правил и ссылается на их идентификаторы.
Если стыков нет — сценариев в файле нет.
Форма «условие → следствие» здесь взята намеренно, хотя как **общая** форма
записи она отклонена: на стыке правил субъект действительно система, и
результат разворачивается во времени — то самое, для чего эта форма и
придумана. Служебные слова блока перечислены ниже и подчиняются тем же
требованиям, что модальные: одна форма на роль, заглавными, набор один на
канон.
| Роль | Русский | Английский |
|---|---|---|
| условие | КОГДА | WHEN |
| следствие | ТОГДА | THEN |
| соединение | И | AND |
| выбор | ИЛИ | OR |
Модальными словами они не являются: обязательности не задают, только
структуру. Поэтому в строку о версии языка они не попадают — там
перечисляется то, чему нужно определение, а логическая связка читается сама,
— и под проверку «модальные слова вне правил» не подпадают.
## Идентификаторы ## Идентификаторы
- Формат — `<ПРЕФИКС>-<номер>`: `KEYS-5`, `SLOG-27`. Префикс принадлежит Идентификаторов в языке два: **правило** адресуется префиксом с номером,
**конвенция целиком** — именем темы. Ссылаться путём к файлу нельзя ни на то,
ни на другое.
**Правило.**
- Формат — `<ПРЕФИКС>-<номер>`: `XKEY-5`, `XLOG-27`. Префикс принадлежит
файлу, нумерация внутри файла сквозная и начинается с единицы. файлу, нумерация внутри файла сквозная и начинается с единицы.
- Строка таблицы, если на неё нужно ссылаться отдельно, — `KEYS-5.1`, - Строка таблицы, если на неё нужно ссылаться отдельно, — `XKEY-5.1`,
`KEYS-5.2`. `XKEY-5.2`.
- **Идентификатор глобален.** Префикс уникален по всему канону, поэтому - **Идентификатор глобален.** Префикс уникален по всему канону, поэтому
путь файла в ссылке не нужен: `KEYS-5` адресует правило одинаково изнутри путь файла в ссылке не нужен: `XKEY-5` адресует правило одинаково изнутри
файла, из соседней конвенции и из чужого репозитория. В собранной копии файла, из соседней конвенции и из чужого репозитория. В собранной копии
слои разных осей лежат в одном документе, так что ссылка на базовый слой слои разных осей лежат в одном документе, так что ссылка на базовый слой
из языкового вообще никуда не ведёт — правило рядом. из языкового вообще никуда не ведёт — правило рядом.
- **Идентификаторы стабильны и не переиспользуются.** Удалённое правило - **Идентификаторы стабильны и не переиспользуются.** Занять номер снятого
оставляет дыру в нумерации; занимать её новым нельзя — иначе ссылка из правила новым нельзя — иначе ссылка из чужого репозитория начнёт указывать
чужого репозитория начнёт указывать на другое утверждение. То же на другое утверждение. То же относится к префиксам: выбывшие хранит манифест
относится к префиксам: выбывшие хранит `prefixes.toml`. набора.
- **Снятое правило остаётся заглушкой.** Заголовок и номер сохраняются, норму
с обоснованием заменяет блок СНЯТО с датой и причиной. Поэтому нумерация в
файле сплошная, а любая ссылка разрешается — либо в правило, либо в
объяснение, почему его сняли (META-31, META-32). Отдельного реестра снятых
номеров нет: он был бы вторым источником правды рядом с файлом.
- Префикс **выбирается под файл, а не выводится по формуле**: он нужен, - Префикс **выбирается под файл, а не выводится по формуле**: он нужен,
чтобы по нему искать, а не чтобы его разбирать. Выводимый префикс вдобавок чтобы по нему искать, а не чтобы его разбирать. Выводимый префикс вдобавок
привязал бы идентификатор к таксономии, которую канон перестраивает, и привязал бы идентификатор к таксономии, которую канон перестраивает, и
упёрся бы в потолок из числа букв алфавита. упёрся бы в потолок из числа букв алфавита.
- Префиксы на букву `X` каноном не занимаются: они принадлежат локальным
правилам репозиториев-потребителей.
- Перенос правила в другой файл — смысловое изменение, а не переименование: - Перенос правила в другой файл — смысловое изменение, а не переименование:
новый файл означает новый префикс и новую нумерацию. Переезд самого файла новый файл означает новый префикс и новую нумерацию. Переезд самого файла
между осями идентификаторы не трогает. между осями идентификаторы не трогает.
@@ -111,90 +510,52 @@
Порядок правил в файле выбирается по читаемости, не по номерам: номер — это Порядок правил в файле выбирается по читаемости, не по номерам: номер — это
идентификатор, а не позиция. идентификатор, а не позиция.
## Правило, чья норма уехала в линтер **Тема.**
Когда правило механизировано у всех потребителей, его норма из канона - **Тема — набор правил об одном фокусе разработки:** время, конфигурация,
удаляется, а обоснование — нет. Остаётся **правило без модальности**, и схема БД. Она же — единица подписки: потребитель берёт тему целиком, а не
чтобы оно не выглядело недописанным, место нормы занимает отметка: отдельные правила.
- Имя темы записывается латиницей. Рекомендуется нижний kebab-case
```markdown (`db-identifiers`), но годится любой идентификатор, пригодный для имени
### MIGR-6. Дефолтов времени в схеме БД нет файла: имя попадает и в файловую систему потребителя, и в его манифест.
- **Тему объявляет файл, а не файловая система.** Имя стоит в шапке
**МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; формулировка (`topic:`) и зарегистрировано в манифесте набора. Слои одной темы несут
удалена, потому что дублировала работающую проверку. одно и то же имя — по нему они и собираются в один документ, как бы ни
назывались их файлы.
**Почему.** Дефолт превращает забытую вставку в тихо работающий код… - **Имя темы не переиспользуется** — как и префикс: оно живёт в шапке
``` `origin:` каждой копии, в подписке манифеста и в тексте ссылок, поэтому
снятое имя уходит в раздел выбывших манифеста, а не достаётся другой теме.
- Идентификатор и заголовок сохраняются: ссылки из репозиториев продолжают - Ссылка на конвенцию — имя темы (конвенция `logging`), ссылка на правило —
указывать на то же утверждение. идентификатор (`XLOG-27`). Путь файла не употребляется ни там, ни там: в
- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не собранной копии путей канона не существует.
сообщает, зачем правило существует, и без обоснования нельзя понять,
когда проверку пора отменять.
- **МЕХАНИЗИРОВАНО** — не шестое модальное слово: оно не задаёт
обязательность, а сообщает, что обязательность теперь обеспечена машиной.
В остальном такое правило равно ДОЛЖЕН.
Факт «механизировано у всех» устанавливается вручную: канон по построению
не знает списка подписчиков, и обойти репозитории перед удалением нормы —
часть работы, а не то, что можно проверить автоматически.
## Таблицы вместо сценариев
Часть правил **классифицирует ситуации**: какой уровень лога, какая
категория директории, что делать с невалидным вводом в зависимости от его
источника. Для них каноническая форма — таблица «ситуация → вердикт»,
строки которой при необходимости нумеруются.
Таблица плотнее прозы и не даёт пропустить ветку: пустая клетка видна, а
неупомянутый случай в абзаце — нет.
## Чего мы не берём из OpenSpec
**GIVEN/WHEN/THEN.** У спецификации субъект — система, и её поведение
разворачивается во времени: состояние, событие, исход. У конвенции субъект
— автор кода, и разворачивать нечего: есть ситуация выбора и вердикт. Это
таблица, а не траектория.
**SHALL.** См. выше про словарь.
**Сценарии как общая форма.** Прозаический сценарий остаётся точечным
инструментом — для **стыка правил**, когда два правила вместе дают
неочевидный результат:
```
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись ERROR
AND тик фонового цикла упал по той же причине → доменная запись WARN
```
Такой блок ставится после обоих правил и ссылается на их номера. Если
стыков нет — сценариев в файле нет.
## Что правилом не является ## Что правилом не является
Модальные слова в этих частях **не употребляются** — иначе перестанет быть Заглавные модальные слова в этих частях **не употребляются** — иначе
понятно, что адресуемо, а что нет: перестанет быть понятно, что адресуемо, а что нет:
- **Область действия** — на что конвенция распространяется во времени - **Область действия** — на что конвенция распространяется во времени
(«новые таблицы; существующие не переписываются»). Это рамка для всех («новые таблицы; существующие не переписываются»). Это рамка для всех
правил файла, а не правило. правил файла, а не правило.
- **Связано** — ссылки на смежные конвенции, ADR, код. - **Связано** — ссылки на смежные конвенции, ADR, код.
- **Локальные регионы** — содержимое принадлежит репозиторию. - **Локальная часть копии** — содержимое принадлежит репозиторию.
- Вводная проза, объясняющая предмет конвенции. - Вводная проза, объясняющая предмет конвенции.
Все четыре части лежат вне областей правил: до первого заголовка правила или
после заголовка, которым область закрылась. Хвост обоснования сюда не
относится — он внутри правила, и модальные слова в нём законны как упоминания.
## Как на правила ссылаются копии ## Как на правила ссылаются копии
В репозитории: Ниже маркера локальной части, в репозитории:
```markdown ```markdown
<!-- local:механизировано --> <!-- conv:local -->
MIGR-2, MIGR-4 — `internal/archrules` (проверяются в новых миграциях).
<!-- /local -->
<!-- local:отступления --> XMIG-2, XMIG-4 — МЕХАНИЗИРОВАНО: `internal/archrules`, в новых миграциях.
MIGR-6 — не соблюдается в легаси-таблицах `show_history`, `queue`: составные
XMIG-6 не соблюдается в легаси-таблицах `show_history`, `queue`: составные
ключи там появились до конвенции, переписывание требует миграции данных. ключи там появились до конвенции, переписывание требует миграции данных.
<!-- /local -->
``` ```
Отсюда видно и то, чего раньше не было видно: конвенция из восьми правил, Отсюда видно и то, чего раньше не было видно: конвенция из восьми правил,
@@ -202,22 +563,83 @@ MIGR-6 — не соблюдается в легаси-таблицах `show_hi
## Что стоит проверять машиной ## Что стоит проверять машиной
Сейчас не реализовано; список — на будущее для `conv`: Проверке подлежит всё, что язык **употребляет**: файлы конвенций и документ,
которым канон сам себя ведёт (в этом каноне — `GUIDE.md`, префикс META). Файл,
который язык **цитирует**, — это описание вроде текущего: ключевые слова стоят
в нём как предмет разговора, а не как норма, и проверки к нему не применяются.
Различает не расположение файла, а роль слова в нём.
- префикс в шапке файла совпадает с реестром, состоит из четырёх заглавных Ни одна проверка пока не реализована, поэтому при ревью их выполняют чтением.
латинских букв и не значится в списке выбывших;
**Форма правила** — разбором текста, в любом файле, который язык употребляет:
- модальные и служебные слова принадлежат объявленному словарю канона, а не
смеси словарей;
- префикс в шапке файла совпадает с манифестом набора, состоит из четырёх
заглавных латинских букв, не начинается на `X` и не значится в списке
выбывших;
- заголовки правил файла используют только его собственный префикс; - заголовки правил файла используют только его собственный префикс;
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило - нумерация внутри файла сплошная: от единицы до наибольшего номера без
берёт следующий свободный, а не первый освободившийся); пропусков, номера не повторяются, новое правило берёт следующий за
- у каждого `### <ПРЕФИКС>-<n>` есть модальное слово (или отметка наибольшим (META-31);
МЕХАНИЗИРОВАНО) и блок «Почему»; - у каждого `### <ПРЕФИКС>-<n>` есть либо модальное слово с нормой и блок
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте канона, хоть в локальных ПОЧЕМУ — ни норма, ни обоснование не удаляются никогда (META-8, META-10), —
регионах копии — указывают на правила, которые ещё существуют; либо блок СНЯТО с датой и причиной;
- чужой префикс не встречается в абзаце с модальностью (META-20); - блок ПРИМЕРЫ, если он есть, стоит после обоснования и не открывает правило:
- путь файла канона не встречается в тексте конвенции (META-21); порядок блоков — норма, ПОЧЕМУ, ПРИМЕРЫ;
- модальные слова не встречаются вне правил. - вводная проза содержит строку о версии языка;
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте набора, хоть в локальной части
копии — разрешаются: неразрешённый идентификатор всегда ошибка (META-32);
- заглавные модальные слова не встречаются вне областей правил (область —
от заголовка правила до следующего заголовка) — кроме строки о версии
языка, которая их перечисляет по назначению;
- модальная метка стоит первой в своём абзаце: заглавное слово в середине
фразы — упоминание ступени, а не вторая норма правила.
## Порядок перевода **Распространение** — разбором текста, только в файлах конвенций: эти проверки
о том, что документ уезжает к потребителю, а документ, которым канон ведёт
себя, не уезжает никуда.
Все конвенции канона записаны на этом языке. Новая конвенция пишется на нём - шапка файла несёт имя темы, и это имя стоит в манифесте набора — среди
сразу; смешение форм в каноне больше не предполагается. живых, а не среди выбывших;
- ось слоя объявлена в шапке, а не выведена из пути; у одной темы не больше
одного слоя без ключей оси — базовый слой единственный;
- если директории осей используются, объявленное в шапке совпадает с путём:
расхождение означает переезд файла без правки шапки;
- имя темы, названное в ссылке или в подписке потребителя, тоже разрешается по
манифесту: ссылка на снятую тему не проходит молча;
- префиксы локальных правил копии начинаются на `X`;
- отметки МЕХАНИЗИРОВАНО в тексте конвенции нет: её место — запись о
механизации в локальной части копии (META-7);
- словарь в коротком описании языка совпадает с этим: те же ступени, те же
метки, те же значения (META-30);
- префикс **чужой темы** не встречается в абзаце с модальностью (META-20);
префикс арх-слоя своей темы там допустим (META-24), префикс другого языка
или стека — нет;
- путь файла канона не встречается в тексте конвенции (META-21).
**Чтением**, потому что машине не даётся:
- строки таблицы взаимоисключающи либо политика совпадения объявлена;
- перечисленные в таблице случаи покрывают область действия;
- норма исполнима без обращения к другим файлам (в файлах конвенций: они
уезжают по одной, а обвязка ссылается на соседей свободно);
- обоснование отвечает на «что сломается», а не пересказывает норму;
- хвост обоснования не вводит требований, которых нет в блоке нормы;
- примеры иллюстрируют норму и не расширяют её: деталь примера не читается
как требование.
Проверки выше — про запись правила. Граница самой темы (не собрала ли она
два решения сразу, нужна ли потребителю целиком) языку не принадлежит: её
вопросы стоят в документе, которым набор ведёт себя.
## Версия языка
Номер версии называется в каждой конвенции, поэтому он двигается, когда
изменение формы способно изменить чтение **уже разданной** копии: копия
ссылается на номер, а не на текст, и обязана читаться по той версии, по
которой написана. Правки формы до того, как копии разошлись, номер не двигают
— читать по ним пока нечего.
Смена словаря под другой естественный язык версию не двигает никогда: версия
принадлежит шкале, меткам и правилам формы, а не буквам.
+134
View File
@@ -0,0 +1,134 @@
---
version: 1
---
# Как читать конвенцию
Файлы рядом с этим — конвенции: правила о том, как в этом репозитории пишут
код. Здесь сказано, как они записаны: что означают заглавные слова, из чего
состоит правило и как на него сослаться. Читается один раз, дальше нужен как
справка.
Файл кладёт сюда сборщик конвенций и перезаписывает целиком при каждом
обновлении. Правки в нём не живут.
## Ключевые слова
Заглавное слово в начале абзаца задаёт обязательность правила.
| Слово | Что означает | Если делаем иначе |
|---|---|---|
| **ДОЛЖЕН** | нарушение считается ошибкой | только с записью в отступления |
| **НЕ ДОЛЖЕН** | запрет, та же строгость | то же |
| **СЛЕДУЕТ** | сильная рекомендация: новый код пишем так | допустимо, причину записываем |
| **НЕ СЛЕДУЕТ** | обратное к СЛЕДУЕТ | то же |
| **ДОПУСКАЕТСЯ** | выбор за автором кода | ничего не требуется — правило не запрещает |
Две вещи, которые легко прочитать неверно:
- **ДОПУСКАЕТСЯ — не бытовое «можно».** У слова есть вторая половина: выбор,
помеченный им, на ревью не обсуждается. Возражение «сделай иначе» против
такого выбора не принимается — иначе разрешение ничего не значило бы.
- **Отступление от ДОЛЖЕН — не запрет на отступление.** Нарушать можно, но
тогда об этом появляется запись: какое правило, где именно, почему. Разница
между ДОЛЖЕН и СЛЕДУЕТ — в том, чем платят за отклонение, а не в том,
возможно ли оно.
Ещё четыре метки заглавными: **ПОЧЕМУ** открывает обоснование правила,
**ПРИМЕРЫ** — код, показывающий норму в деле, **МЕХАНИЗИРОВАНО** стоит при
записи о том, что правило проверяет линтер или скрипт, **СНЯТО** — на месте
правила, которое убрали.
Заглушка со СНЯТО занимает место убранного правила вместе с его номером:
так нумерация остаётся сплошной, а ссылка на снятое правило приводит к
объяснению, а не в пустоту. Требований в такой заглушке нет.
```markdown
### XLOG-4. Уровень записи выбирался по громкости отказа
**СНЯТО 2026-05-14.** Заменено на XLOG-8: громкость каждый оценивал
по-своему, и шкала расползалась.
```
**Нормативно только заглавное написание.** Строчное «должен» в прозе — обычная
речь, а не норма; спорить с ней как с правилом не нужно.
## Из чего состоит правило
Правило в примере вымышленное: префиксы на `X` общий набор не занимает
никогда, поэтому пример нельзя спутать с настоящим правилом.
```markdown
### XLOG-8. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
громко сломалось».
| № | Уровень | Кому и когда |
|---|---|---|
| XLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| XLOG-8.2 | `INFO` | владельцу, аудит постфактум |
**ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково…
```
- **Идентификатор и заголовок.** `XLOG-8` — адрес правила: по нему на правило
ссылаются, им помечают отступления и механизацию.
- **Модальность с нормой.** Собственно требование, одной фразой. Таблица или
список сразу за модальным словом — часть нормы: она уточняет вердикт, и на
её строку ссылаются номером (`XLOG-8.2`).
- **ПОЧЕМУ.** Зачем правило существует и что сломается, если сделать иначе.
Обоснование ничего не требует — по нему решают, применимо ли правило к
случаю, и видно, когда причина отпала.
- **ПРИМЕРЫ** — необязательный последний блок: код, обычно парой «плохо →
хорошо». Иллюстрация, а не спецификация: деталь примера требованием не
становится, дословно копировать его не нужно, а если пример разошёлся с
нормой — действует норма.
**Правило кончается перед следующим заголовком.** Абзацы после ПОЧЕМУ — это
продолжение обоснования: примеры, разбор границ, ссылки на внешние практики.
Требований в них нет; всё, что подлежит исполнению, стоит в блоке нормы.
## Как ссылаться
- На **правило** — идентификатором: `XLOG-27`. Путь к файлу не нужен,
идентификатор уникален.
- На **конвенцию целиком** — именем темы: конвенция `logging`. Имя темы стоит
в шапке файла (`origin:`).
- Строка таблицы адресуется номером с точкой: `XLOG-8.2`.
## Что ниже маркера
```markdown
<!-- conv:local -->
```
Всё выше маркера приезжает из общего набора и перезаписывается при
обновлении. Всё ниже принадлежит этому репозиторию и обновление переживает.
Там живёт:
- **отступления** — какое правило не соблюдается, где и почему: «`XMIG-6` не
соблюдается в `queue`: составные ключи там появились до конвенции»;
- **механизация** — кто проверяет правило машинно: «`XMIG-4`
МЕХАНИЗИРОВАНО: `internal/archrules`»;
- **разрешение условий**, которые правило оставило открытыми;
- **свои правила** — по той же форме, но с префиксом на `X` (`XLOG-1`).
Префиксы на `X` общий набор не занимает никогда, так что столкнуться они не
могут.
Правка выше маркера живёт до первого обновления и исчезает молча. Если
исправить нужно приехавший текст — либо правку переносят в общий набор, либо
файл перестаёт быть копией: из шапки убирают `origin:`.
## Чего в конвенции не бывает
- **Утверждений о том, как сейчас устроен этот репозиторий.** Правило пишется
в предписывающем времени; «у нас пока не так» — это отступление, и его
место ниже маркера.
- **Заглавных ключевых слов вне правил.** Во вводной прозе, в разделах
«Область действия» и «Связано» их нет, поэтому искать там требования не
нужно.
Язык записи — версия 1. Полное описание живёт в наборе конвенций, у автора;
здесь ровно то, что нужно читателю.
+304 -88
View File
@@ -3,22 +3,23 @@
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
`docs/conventions/`, коммитят их и живут дальше самостоятельно — как с `docs/conventions/`, коммитят их и живут дальше самостоятельно — как с
`ansible-roles`: канон не источник истины во время работы, а лавка, из `ansible-roles`: канон не источник истины во время работы, а лавка, из
которой берут и в которую возвращают улучшения. которой берут.
Сами конвенции лежат в `conventions/`, обвязка — в корне: Сами конвенции лежат в `conventions/`, обвязка — в корне:
| Файл | Что описывает | | Файл | Что описывает |
|---|---| |---|---|
| `README.md` | устройство канона, оси, синхронизация, жизненный цикл | | `README.md` | устройство канона, оси, сборка копий, жизненный цикл |
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» | | [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления | | [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
| `prefixes.toml` | реестр префиксов правил | | [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
| `conv` | синхронизация копий | | `.conventions-suite.toml` | манифест набора: язык, темы, префиксы правил |
Обвязка живёт только в каноне и в репозитории не оказывается — `conv` К потребителю едет содержимое `conventions/` и один файл обвязки —
синхронизирует лишь содержимое `conventions/`. Пока это осознанное `READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
ограничение: копия конвенции ссылается на `LANGUAGE.md` как на внешний не нарушает: конвенция называет язык записи одной
документ. строкой с номером версии и не ссылается на путь (`LANGUAGE.md`, раздел «Ссылка
на язык из конвенции»).
Правило то же, что у ролей: **деплоится и читается только то, что лежит в Правило то же, что у ролей: **деплоится и читается только то, что лежит в
git репозитория**. Канон никем не подключается на лету. git репозитория**. Канон никем не подключается на лету.
@@ -28,7 +29,7 @@ git репозитория**. Канон никем не подключаетс
Конвенция формулируется независимо от конкретного приложения. Она задаёт Конвенция формулируется независимо от конкретного приложения. Она задаёт
правило; код ему следует. Обратное направление запрещено: то, что правило; код ему следует. Обратное направление запрещено: то, что
приложение уже делает иначе, **не является аргументом против правила** — это приложение уже делает иначе, **не является аргументом против правила** — это
отступление, и его место в локальном регионе того репозитория, а не в отступление, и его место в локальной части копии того репозитория, а не в
переформулировке канона. переформулировке канона.
Отсюда практические следствия: Отсюда практические следствия:
@@ -51,11 +52,27 @@ conventions/
stack/<стек>/ привязка к инструменту, хранилищу, транспорту stack/<стек>/ привязка к инструменту, хранилищу, транспорту
``` ```
Пути **файлов** даются относительно `conventions/`: `arch/db-identifiers.md`, Ось файл **объявляет в шапке**, а не наследует от директории (META-38):
а не `conventions/arch/…` — так же, как они лягут в `docs/conventions/`
репозитория. На **правила** ссылаются идентификатором без пути: `KEYS-5`. ```yaml
Префикс уникален по всему канону (реестр — `prefixes.toml`), поэтому topic: logging
идентификатор не зависит от того, на какой оси файл лежит сегодня. prefix: SLOG
lang: go
```
Ключей оси нет — базовый слой темы. Слова те же, что в подписке потребителя
(`lang`, `stack`), так что переводить между двумя сторонами нечего. Дерево
директорий повторяет объявленное для человека и остаётся раскладкой
**канона**: в репозитории копия лежит плоско, файлом на тему. Пути файлов
даются относительно `conventions/` (`arch/db-identifiers.md`) и адресуют
исходник канона, а не место в копии. На **правила** ссылаются идентификатором
без пути: `KEYS-5`. Префикс уникален по всему канону (он перечислен в
манифесте набора), поэтому идентификатор не зависит ни от оси, ни от того, как
собран файл у потребителя.
Объявление вдобавок выражает то, чего дерево не умеет: слой, осмысленный
только при совпадении языка и инструмента сразу (`lang: go` и `stack: slog` в
одной шапке).
Тест — по тому, замена чего убивает правило: Тест — по тому, замена чего убивает правило:
@@ -80,6 +97,63 @@ conventions/
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая пласт `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`: роль
принадлежит сегодняшнему устройству одного репозитория и молча начинает врать,
а суффиксная пара вдобавок навязывает чтение «две разновидности одного», хотя
по границе темы это разные решения — общего у них три правила из сорока.
## Префиксы ## Префиксы
Каждый файл канона объявляет в шапке свой префикс правил: Каждый файл канона объявляет в шапке свой префикс правил:
@@ -88,10 +162,23 @@ conventions/
prefix: KEYS prefix: KEYS
``` ```
Четыре заглавные латинские буквы, уникальные по всему канону; реестр — Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
[`prefixes.toml`](prefixes.toml). Префикс выбирается под файл, а не выводится манифесте набора, секция `[prefixes.live]`, путём **от корня репозитория**, а
по формуле, и не переиспользуется никогда. Правила адресуются идентификатором не от `conventions/` — манифест покрывает и обвязку тоже. Префикс выбирается
`KEYS-5` — без пути к файлу. Подробности формы — `LANGUAGE.md`. под файл, а не выводится по формуле, и не переиспользуется никогда. Правила
адресуются идентификатором `KEYS-5` — без пути к файлу. Подробности формы —
`LANGUAGE.md`.
`GUIDE.md` тоже несёт префикс и тоже проверяется как конвенция: правила в нём
записаны тем же языком и цитируются по номерам. Темы у него нет — подписаться
на него нельзя, к потребителю он не едет, — и манифест называет его отдельным
ключом `governance`, чтобы конвенция, потерявшая `topic`, не сошла за него.
Буква `X` в начале префикса зарезервирована за репозиториями: канон её не
занимает никогда, а локальные правила потребителя берут префиксы только на
неё (`XTIM`, `XLOG`). Так столкновение локального префикса с будущим
префиксом канона невозможно по построению, и согласовывать заранее ничего не
нужно.
## Расширение ## Расширение
@@ -106,67 +193,170 @@ extends: arch/db-identifiers.md
неверно сформулировано условие применимости (чинится в каноне), либо неверно сформулировано условие применимости (чинится в каноне), либо
репозиторий на базу просто не подписан. репозиторий на базу просто не подписан.
`extends` — документация связи, а не механизм: `conv` о ней только `extends` — документация связи, а не механизм: за тем, чтобы база лежала
напоминает при `add` и никак не следит за тем, чтобы база лежала рядом. рядом, никто не следит. С объявленной осью база к тому же находится сама —
это слой той же темы без ключей `lang` и `stack`, — так что ключ остаётся
подсказкой человеку и ничего не выбирает.
## Служебная разметка ## Компонент — адресат сборки
**Шапка копии** ставится при `conv add` и в каноне не хранится: Подписка принадлежит репозиторию, а собранный документ адресован не
репозиторию, а куску кода. Пока проект однороден, разницы нет; два языка её
проявляют: `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 ```yaml
--- ---
origin: arch/time.md # откуда взято origin: time
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
synced: 2026-07-25
local: нет # или: чем и почему разошлись
--- ---
``` ```
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать В `origin:` стоит имя темы — то же, что в манифесте набора и в шапках
«канон обновился» от «изменено локально»; без него `status` умеет только `topic:` слоёв, из которых файл собран. Больше в шапке ничего нет: отпечатка
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки канона и даты синхронизации в ней не хранится, потому что обновление
(`prefix`, `extends`) — часть документа: они сравниваются наравне с телом. перезаписывает файл в рабочем дереве, и что именно изменилось, показывает
`git diff` до коммита. Второй механизм сравнения рядом с git не нужен.
**Локальные регионы** — куски, принадлежащие репозиторию по определению. **Маркер локальной части** — единственная машинно значимая разметка внутри
Из сравнения исключаются, поэтому вечного шума в `diff` не дают: файла:
```markdown ```markdown
<!-- local:механизировано --> <!-- conv:local -->
`AUTOINCREMENT` в новых миграциях — `internal/archrules`.
<!-- /local --> MIGR-2, MIGR-4 — МЕХАНИЗИРОВАНО: `internal/archrules`.
MIGR-6 не соблюдается в `show_history`, `queue`: составные ключи там
появились до конвенции, миграция данных не окупается.
``` ```
Имя обязательно — перенос при `pull` идёт по именам, безымянные регионы Всё ниже маркера принадлежит репозиторию и переживает обновление; всё выше —
`conv` отвергает. Что всегда локально: пересобирается из канона. Маркер один и безымянный, поэтому у него нет
имени, которое можно осиротить переименованием.
- **механизация** — канон не знает, у кого линтер уже настроен; Ниже маркера живёт то, чего канон о репозитории не знает: механизация,
- **отступления** — «у нас пока не так», честно и поимённо; отступления, разрешение условий («Здесь: INTEGER PK, id наружу не выходят»),
- **разрешение условия** — «Здесь: INTEGER PK, id наружу не выходят»; ссылки на ADR и код, а также **собственные правила**с префиксом на `X`,
- **эталоны и ссылки** — имена функций, файлов, ADR конкретного репозитория; по тем же правилам формы, что и канон.
- **список конвенций** в README репозитория.
Путь файла в каноне и имя региона — это API: переименование осиротит все Если местных правок стало больше, чем каноничного текста, копия перестаёт
копии (`origin` строковый). Переименовывать — только вместе с обходом быть копией: `origin:` из шапки убирают, и дальше это обычный документ
потребителей. репозитория. Файл, оставивший шапку, при следующем обновлении потеряет
всё, что выше маркера.
После `pull` копию нужно перечитать глазами: содержимое региона могло ## Язык записи едет вместе с копиями
устареть относительно переписанного вокруг текста, и автоматика этого не
увидит.
## Раскладка в репозитории Конвенция называет язык одной строкой с номером версии и без пути — строка
работает и сама по себе. Но семантика заглавных слов живёт в описании языка, а
описание в репозиторий-потребитель раньше не попадало: агент, читающий копию,
принимал ДОПУСКАЕТСЯ за бытовое «можно» и терял ровно то, ради чего слово
введено.
Копии повторяют структуру канона: Поэтому в `docs/conventions/` сборщик кладёт `READING.md` — короткое описание
для читателя правил: словарь со значениями, правило заглавных, из чего состоит
правило и где его граница, как ссылаться, что живёт ниже маркера. Полное
[LANGUAGE.md](LANGUAGE.md) остаётся в каноне: три его раздела адресованы
автору набора и ссылаются на правила `GUIDE.md`, которых у потребителя нет.
``` Два документа — один словарь, и это единственное место, где возможен дрейф.
docs/conventions/ Правка ключевых слов или состава частей правила обязана дойти до `READING.md`
README.md собственный, не синхронизируется (META-30), а сверить их дёшево: таблицы либо совпадают, либо нет.
arch/db-identifiers.md
lang/go/db-identifiers.md ## Два манифеста
Манифестов в модели два, и они отвечают на разные вопросы:
| Файл | Где лежит | Что описывает |
|---|---|---|
| `.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` выбирают строку разреженной матрицы: файл темы собирает
видимый в `git ls-files`. README директории перечисляет их одной плоской только те слои, которые компоненту подходят, и совпадают со словами, которыми
таблицей, чтобы вложенность не мешала навигации. слой объявил свою ось. `topics` — подписка, именами из манифеста набора;
списка подписчиков у канона по-прежнему нет, список подписок есть только у
потребителя.
Компонент пишется всегда, даже когда он один: сокращённая плоская форма
сэкономила бы три строки и завела бы второй способ сказать то же самое.
Имя компонента при этом не служебное — им сборщик отвечает, что и куда
собрал. У плоского набора `lang` и `stack` в компоненте просто отсутствуют.
Как именно инструмент добирается до канона — путь на диске, git, HTTP —
дело инструмента, а не модели. Манифест отвечает на два вопроса: откуда это
взято и где искать обновления.
Отдельного лок-файла нет. Он отвечал бы на «что было в прошлый раз», а на
это отвечает git: копии закоммичены, автоматического обновления не
существует, и любое изменение проходит через чтение диффа человеком.
## Контракт с агентом ## Контракт с агентом
@@ -174,46 +364,72 @@ docs/conventions/
любого другого документа. Это главный канал тихого дрейфа, поэтому любого другого документа. Это главный канал тихого дрейфа, поэтому
`AGENTS.md` каждого потребителя должен явно говорить: `AGENTS.md` каждого потребителя должен явно говорить:
> Файлы в `docs/conventions/` с шапкой `origin:` — копии из канона > Файлы с шапкой `origin:` в директориях конвенций (пути — в
> `dev-conventions`. Репозиторное пишется только внутрь > `.conventions.toml`) — копии из канона `dev-conventions`. Репозиторное
> `<!-- local:… -->`. Правка вне регионов — либо `conv push` в канон, либо > пишется только ниже `<!-- conv:local -->`; всё выше маркера
> запись причины в `local:`. > перезаписывается при обновлении. Своё правило — с префиксом на `X`.
## Команды ## Команды
```bash Копии собирает `convy` — отдельный инструмент, живущий в своём репозитории и
conv list # что есть в каноне ставящийся бинарём. Запускают его из корня репозитория-потребителя:
conv add arch/time.md # взять к себе (можно несколько за раз)
conv status # ok / изменено локально / канон обновился / разошлись
conv diff [arch/time.md] # чем копия отличается, без учёта локальных регионов
conv pull arch/time.md # забрать обновление канона (регионы переносятся)
conv push arch/time.md # вернуть локальное улучшение в канон
conv push --new lang/go/x.md # завести в каноне новую конвенцию
```
`status` и `diff` всегда завершаются кодом 0: это отчёт, а не проверка.
Расхождение — нормальное состояние, а постоянный шум в `diff` означает не
«догони канон», а «пора разрезать файл». Симметрично: разросшийся до спора
с базой локальный регион означает «пора чинить условие применимости в
каноне».
Запускать из корня репозитория:
```bash ```bash
~/projects/private/dev-conventions/conv status 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 # проверить форму того, что лежит здесь
``` ```
Обёртка в раннере репозитория (`inv conventions -- status` для ansible, При одном компоненте `--for` не нужен. При нескольких команда без него не
`task conventions -- status` для Go) — тонкий проброс аргументов, чтобы угадывает, а отказывает и перечисляет имена.
Манифест подключения правится и руками — это данные, а не текст с
комментариями. Что бы в нём ни поменяли, раскладку под него подводит `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) — тонкий проброс аргументов, чтобы
логика не размножалась по репозиториям в двух диалектах. логика не размножалась по репозиториям в двух диалектах.
## Жизненный цикл ## Жизненный цикл
- **В канон.** Новая конвенция пишется в том репозитории, где заболело, и - **В канон.** Новая конвенция пишется в том репозитории, где заболело, и
продвигается `conv push --new`. Локальные регионы при этом опустошаются: переносится в канон, когда стало ясно, что общего в ней больше, чем
в канон едет только норма. местного. Локальная часть при этом не едет: в канон попадает только норма,
а префикс на `X` меняется на канонический — то есть правила получают новые
идентификаторы.
- **Из канона.** Устаревшая конвенция удаляется вместе с обходом - **Из канона.** Устаревшая конвенция удаляется вместе с обходом
потребителей — тихо осиротить копии нельзя. потребителей — тихо осиротить копии нельзя.
- **История.** Канон коммитится при каждом `push`: `origin_hash` отвечает - **История.** Канон коммитится при каждой правке: только git канона
на «отличается ли», но только git канона отвечает на «почему база отвечает на вопрос, почему база сформулирована так.
сформулирована так».
## Состояние
Модель выше реализована в `convy`: сборка копий, отбор слоёв по объявленной
оси, маркер локальной части, `READING.md` рядом с копиями, проверка
целостности набора. Прежний питоновский `conv` — с зеркальным деревом,
именованными регионами и `origin_hash` — удалён вместе со своей моделью.
Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:` в
природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт
в чужом репозитории через полгода после первой сборки.
+100 -196
View File
@@ -1,118 +1,91 @@
# К обсуждению # К обсуждению
Черновик для следующего разговора: вопросы и варианты, а не принятые Черновик для следующего разговора: вопросы и варианты, а не принятые
решения. решения. Закрытый вопрос отсюда удаляется — принятое решение живёт в
`README.md`, `GUIDE.md` или `LANGUAGE.md`, а не в этом файле.
## 1. Ссылка на `LANGUAGE.md` не переживает сборку Вопросы про инструмент здесь не живут — они собраны в его собственном
репозитории.
Все двенадцать конвенций во вводной прозе пишут «Форма записи — Две секции: сначала язык и подход, потом сам набор и подключение.
`LANGUAGE.md`». Обвязка в репозиторий не едет (`conv` синхронизирует только
`conventions/`), поэтому в собранной копии эта ссылка указывает в никуда.
Это тот же класс дефекта, который вчера закрыло META-21 для ссылок между # Язык и подход
темами: путь, верный в каноне, молча умирает при сборке. Разница в том, что
META-21 предлагает заменить путь на имя темы — а здесь заменять не на что,
целевого документа в репозитории просто нет.
Варианты, которые видно сейчас: ## 1. Одиннадцать таблиц не прочитаны на взаимоисключительность
- **Убрать упоминание из тел конвенций.** Язык записи — забота того, кто `LANGUAGE.md` объявил, что строки таблицы решений взаимоисключающи по
пишет канон, а не того, кто читает конвенцию в своём репозитории. Читателю умолчанию, а иной порядок объявляется явно. Объявление не делает таблицы
достаточно самого текста: модальные слова и «Почему» самоописательны. такими: нумерованные строки есть в одиннадцати файлах канона, и ни одна
Дешевле всего, но копия теряет указание, по каким правилам её править. таблица под новое требование не прочитана.
- **Возить обвязку вместе с конвенциями.** Тогда `LANGUAGE.md` и, возможно,
`GUIDE.md` появляются в `docs/conventions/` как ещё одни синхронизируемые
файлы. Честно, но противоречит нынешнему разделению «канон — конвенции,
корень — обвязка» и добавляет в репозиторий текст, который агенту при
чтении конвенции не нужен.
- **Вкладывать короткую преамбулу в собранный файл.** Сборщик пишет в шапку
три-четыре строки: что такое модальное слово, что «Почему» обязательно,
где лежит полный документ. Самодостаточно и не тащит весь язык, но
преамбула дублируется в каждом файле темы.
Сопутствующее: `GUIDE.md` в телах конвенций не упоминается ни разу — Конкретный подозреваемый — SLOG-11: «по реальному действию или изменению»
проверено, так что вопрос только про `LANGUAGE.md`. против «повторяющаяся служебная, по таймеру или поллингу». Периодическая
операция, которая всё-таки меняет данные, подходит под обе строки, и уровень
из таблицы не выводится однозначно.
## 2. Тулинг: две разные задачи в одном `conv` Работа читательская, машине не даётся; в список проверок она уже записана в
разделе «Чтением, потому что машине не даётся».
Сейчас в `conv` смешаны две категории работы, и они расходятся по всему — ## 2. Шесть сниппетов сидят в блоке нормы
по частоте запуска, по тому, кто запускает, и по тому, что считается
провалом.
**Целостность канона.** Префиксы уникальны и не переиспользованы, шапка С появлением блока ПРИМЕРЫ у кода в правиле есть своё место, но шесть правил
совпадает с реестром, номера без дыр вниз, у каждого правила модальность и несут сниппет **внутри блока нормы** — там, где он по границе правила читается
«Почему», ссылки разрешаются, чужой префикс не лезет в норму (META-20), как «требуется ровно такой код»: GCFG-7, GCFG-9, SLOG-20, GTIM-8, HTMX-7,
путей канона в тексте нет (META-21). Запускается в каноне, при каждой HTMX-24. Кода внутри обоснований в каноне нет ни одного, так что разбирать
правке, провал — это ошибка. Логика уже написана и много раз прогнана нужно только эти шесть.
руками, но живёт в скретчпаде, а не в репозитории.
**Установка в проект.** Манифест `.conventions.toml`, сборка файла темы из Разбор по одному, вердикт из двух: сниппет — часть требования или иллюстрация
секций (arch → языки → стеки → local), сохранение локальной секции при к нему. У GTIM-8 (`ReplaceAttr` с приведением к UTC) это похоже на норму: там
пересборке, отчёт «канон ушёл вперёд», предупреждение о висячих ссылках на важна конкретная точка вмешательства. У HTMX-7 и SLOG-20 — скорее иллюстрация
неподписанные темы. Запускается в репозитории-потребителе, изредка, провал — формы вызова, и ей место в ПРИМЕРЫ.
это чаще «посмотри глазами», чем «ошибка».
Что обсудить: Цена ошибки в обе стороны понятна. Оставленный в норме пример превращает
деталь кода в требование, которое никто не имел в виду, и устаревает вместе с
API, а норму при этом нельзя поправить, не задев требование. Унесённая в
ПРИМЕРЫ норма, наоборот, перестаёт быть обязательной — блок иллюстративный.
- Разделять ли на два исполняемых файла, или хватит подкоманд с честной ## 3. Описание языка отдельно от набора конвенций
границей внутри.
- Валидацию канона стоит ли отдать агенту скиллом вместо (или вдобавок к)
скрипту: часть проверок формулируется как «прочитай и скажи, самодостаточна
ли норма» — механически это не берётся, а агентом берётся.
- Куда в этой раскладке ложится запаркованное предупреждение о висячих
ссылках: это установка, а не целостность, но список подписок ему нужен из
манифеста.
## 3. Согласованная модель сборки нигде не записана `LANGUAGE.md` и `GUIDE.md` описывают, **как** пишутся конвенции;
`conventions/`**один конкретный** набор. Сейчас они склеены в одном
репозитории, и из одного описания нельзя собрать второй набор (рабочий,
доменный, чужой).
Самое срочное. Договорённости про плоскую раскладку живут только в Срочности нет: версия языка объявлена в самом `LANGUAGE.md` (ключ
переписке, а репозиторий описывает прежнюю модель — и противоречит новой в `version:`), и конвенции ссылаются на неё номером, а не путём, — то есть
нескольких местах сразу. самодостаточность копии выноса не требует. Примеры в `LANGUAGE.md` вдобавок
переведены на вымышленные `X`-правила, так что на конкретный набор описание
языка больше не ссылается вовсе.
Что решено, но не зафиксировано: Что осталось поводом:
- копия плоская, файл на тему: `docs/conventions/config.md`, а не дерево - из одного описания по-прежнему нельзя собрать второй набор;
`arch/` + `lang/`; - тулинг валидирует правила, зашитые в его код, а не объявленную версию
- файл темы собирается из секций `arch → языки → стеки → local` с языка.
машиночитаемыми маркерами `<!-- conv:section … -->` и `<!-- conv:local -->`;
- языки и стеки образуют разреженную матрицу; файл темы собирает её строку,
многоязычная тема держит несколько языковых секций в одном файле;
- выбор описывается манифестом `.conventions.toml` в корне
репозитория-потребителя; путь к канону там **не** хранится;
- направление строго одностороннее: канон → код. Правка канона делается
руками в каноне, потом пересборка;
- локальные префиксы правил репозитория объявляются в манифесте и не
пересекаются с реестром канона.
Что этому прямо противоречит в репозитории сейчас: Оба повода включаются, только когда появится второй набор. Цена — ещё одна
сущность и ещё одна синхронизация; при одном наборе лечение выходит хуже
болезни.
- `README.md` → «Раскладка в репозитории» показывает зеркальное дерево # Канон и подключение
`docs/conventions/arch/db-identifiers.md`;
- `README.md` → «Команды» и «Контракт с агентом» описывают `conv push` и
`conv push --new` как штатный путь; при односторонней модели транспорт
назад исчезает, остаётся только детект «копия правлена вне локальной
секции»;
- `conv` содержит `cmd_push` со всей обвязкой (`--new`, `--force`).
## 4. Именованные регионы → одна локальная секция ## 4. Значения осей нигде не зарегистрированы
Решено заменить регионы `<!-- local:имя -->` на одну локальную секцию в Ось теперь объявлена в шапке (META-38), но её значения не сверяются ни с чем:
конце собранного файла: отступление ссылается на идентификатор правила `lang: golang` вместо `lang: go` соберётся молча — слой просто не попадёт ни в
(`DIRS-5`), а не стоит рядом с ним. Это то, что делает одну копию. Это ровно та болезнь, от которой лечили тему (META-28): объявление
сравнение копии с каноном одним хешем и выкидывает из `conv` перенос без реестра проверяется только глазами.
регионов по именам.
Сделать перенос ещё предстоит: в каноне сейчас **31 регион в 12 файлах**. Напрашивается секция в `.conventions-suite.toml` рядом с `[topics.live]` и
`[prefixes.live]` — перечень живых языков и стеков с однострочным описанием, и
те же правила выбытия. Против: третий реестр в манифесте, а значений сегодня
три (`go`, `ansible`, `htmx`). За: словарь общий у двух сторон — им же
потребитель пишет `lang` и `stack` в своём компоненте, и опечатка там стоит
столько же.
``` Заодно решается судьба `extends:`: с объявленной осью база находится сама —
7 связано 7 отступления 7 механизировано это слой той же темы без ключей оси, — так что ключ остался подсказкой
1 эталон / эталоны / словарь / секреты / секретные-поля человеку и кандидат на снятие.
1 проверки / поля / модель-владельца / маппинг / границы
```
Отдельно решить, что делать с `связано`: сейчас это репо-специфичная часть
раздела «Связано» (META-17), и при единой локальной секции она переезжает
туда же — надо проверить, что META-17 после этого не противоречит сам себе.
## 5. Пары слоёв и темы без базы ## 5. Пары слоёв и темы без базы
@@ -121,7 +94,9 @@ META-21 предлагает заменить путь на имя темы —
- `SLOG` объявляет `extends: arch/time.md` — расширение **чужой** темы. - `SLOG` объявляет `extends: arch/time.md` — расширение **чужой** темы.
Сборщик темы `logging` на это наткнётся: базового слоя с темой `logging` Сборщик темы `logging` на это наткнётся: базового слоя с темой `logging`
нет, а `arch/time.md` он тянуть не должен. Чинится переводом в обычную нет, а `arch/time.md` он тянуть не должен. Чинится переводом в обычную
ссылку «связано». ссылку «связано». Вдобавок это ломает гарантию META-24: она верна только
для базы своей темы, а `arch/time.md` объявляет тему `time`, не `logging`.
С объявленной темой расхождение стало проверяемым машинно.
- Темы без арх-слоя: `db-schema`, `errors`, `web-ui`. Собираются в файл с - Темы без арх-слоя: `db-schema`, `errors`, `web-ui`. Собираются в файл с
одной секцией — само по себе не ломается, но это и есть тот невыделенный одной секцией — само по себе не ломается, но это и есть тот невыделенный
арх-слой из известного долга. арх-слой из известного долга.
@@ -130,111 +105,40 @@ META-21 предлагает заменить путь на имя темы —
проверить, что так и задумано. проверить, что так и задумано.
- В `lang/go/db-schema.md` сидит целый пласт `stack/sqlite/` (типы колонок), - В `lang/go/db-schema.md` сидит целый пласт `stack/sqlite/` (типы колонок),
тоже из известного долга README. тоже из известного долга README.
- Вынос арх-ядра из `errors` и `logging` закроет две хрупкие ссылки из
`web-ui` (стек) в go-слой — единственные ссылки стек → язык в каноне.
## 6. Подключение к репозиториям ## 6. Восемь тем не прогнаны по границе
Критерии границы записаны правилами (META-33 … META-37), но ни одна тема по
ним не прочитана. Работа по одной теме: выписать вопрос темы одной фразой и
пройти по правилам, помечая чужие.
Два подозреваемых видно уже сейчас.
`time` собрана вокруг вещества, а не решения (META-33): TIME-2 и TIME-3
(ширина и точность на носитель), TIME-6 (дефолтов в схеме БД нет), GTIM-4
(20 символов в БД), GTIM-8 и GTIM-9 (UTC и точность в логах) выносят вердикты
тем `db-schema` и `logging`. Остаток — представление момента, единая точка
«сейчас», длительность и часы, не-UTC на отображении — тема настоящая. Разрез
попутно снимает `extends: arch/time.md` из вопроса 5.
`logging` лежит целиком в `lang/go`, хотя внутри три страта: уровень по
адресату, «ошибка логируется один раз на границе», секреты — не про Go;
`JSONHandler`, `stdout`, разбор через `jq`/DuckDB — про стек; SLOG-30…33
(входящий запрос, healthcheck, 4xx) — про вид приложения (META-36), то есть
про веб-сервис. Здесь же лежит невыделенное арх-ядро из вопроса 5.
Отдельно проверить обратное: `errors` без арх-слоя — не дефект. Обработка
отказа в плейбуке (`failed_when`, `block`/`rescue`, идемпотентность) го-шные
правила не сужает, а решает другую задачу, значит для плейбуков это своя
тема, а не слой в `errors` (META-35).
## 7. Подключение к репозиториям
Ничего ещё не подключено. Кандидаты — jellybit и pet-project-server. Ничего ещё не подключено. Кандидаты — jellybit и pet-project-server.
Понадобится: заполнить локальные секции тем, что сейчас в этих репозиториях Понадобится: заполнить локальную часть копий тем, что сейчас в этих
записано по факту; обёртка в раннере (`inv conventions` / `task conventions`, репозиториях записано по факту; обёртка в раннере (`inv conventions` /
единый интерфейс команд у трёх ansible-репозиториев); строка в `AGENTS.md` `task conventions`, единый интерфейс команд у трёх ansible-репозиториев);
каждого потребителя про то, что файлы в `docs/conventions/` — копии. строка в `AGENTS.md` каждого потребителя про то, что файлы в директориях
конвенций — копии. Компонент у обоих кандидатов один, но записывается явно.
Открытый кусок с прошлого раза: **как копия ссылается на канон, не ломая
самодостаточность**. Абсолютный путь `~/projects/private/dev-conventions`
в закоммиченном файле не годится — репозиторий перестаёт быть
самодостаточным и получает хардкод путей. Пересекается с вопросом 1.
## 7. Не переизобретено ли это
Проверить перед тем, как вкладываться в тулинг. Ближайшие кандидаты, куда
смотреть:
- **copier / cruft** — шаблон проекта с последующим `update`: ровно та же
задача «стянуть обновление апстрима, не затерев локальные правки», с
ответом через три-way merge вместо наших регионов. Стоит понять, почему у
них merge, а у нас исключение из сравнения.
- **vendir** — вендоринг чужого содержимого с лок-файлом; ближе к нашей
модели «канон это лавка».
- **Vale** — линтер прозы с правилами в файлах: значительная часть проверок
целостности канона (модальные слова вне правил, запрещённые формулировки)
выражается его языком.
- **RFC 2119 / 8174** — канонический источник модальных слов; наш словарь
фактически его перевод, полезно сверить границы значений.
- **EARS** — шаблоны требований (ubiquitous / event-driven / state-driven);
соседняя формализация того же, что мы решили таблицами.
- **Наборы правил для агентов** — `AGENTS.md`, cursor rules, скиллы: задачу
«раздать читаемые агентом договорённости по репозиториям» сейчас решают
несколько продуктов, и там уже могли устояться форматы.
## 8. Мультиязычность ключевых слов
Модальные слова сейчас русские, и это осознанно: разный словарь держит
границу «конвенция — не спецификация» бесплатно. Вопрос — нужен ли
английский набор параллельно.
За: канон может однажды понадобиться на английском; агенты натренированы на
MUST/SHOULD плотнее, чем на ДОЛЖЕН/СЛЕДУЕТ. Против: два набора — это два
способа записать одно, и проверка «модальные слова не встречаются вне
правил» усложняется вдвое.
Если делать, то таблица ключевых слов должна принадлежать **описанию
языка**, а не каждой конвенции — то есть вопрос завязан на следующий.
## 9. Описание языка отдельно от набора конвенций
`LANGUAGE.md` и `GUIDE.md` описывают, **как** пишутся конвенции;
`conventions/`**один конкретный** набор. Сейчас они склеены в одном
репозитории, и из одного описания нельзя собрать второй набор (рабочий,
доменный, чужой).
Что это даёт, если разнести:
- канон объявляет, какой версии языка следует, а тулинг валидирует набор
**против объявленного описания**, а не против зашитых в код правил;
- таблица модальных слов (вопрос 8) живёт в описании и одна на все наборы;
- вопрос 1 (`LANGUAGE.md` не переживает сборку) меняет форму: собранный файл
ссылается не на путь, а на язык с версией.
Цена — ещё одна сущность и ещё одна синхронизация. Проверить, не выйдет ли
лечение хуже болезни при одном пользователе.
## 10. Тулинг на Go, живущий независимо
Сейчас `conv` — питоновский скрипт внутри канона, то есть тулинг и данные в
одном репозитории и правятся одним движением. Мысль: вынести в отдельный
Go-бинарь со своим релизным циклом, ставить через `eget` (механизм уже есть
в pet-project-server).
Что за этим стоит помимо вкуса: независимый бинарь физически не даёт править
инструмент «заодно» с правкой конвенции, работает против **любого** канона и
любого потребителя — что прямо требуется вопросом 9, — и снимает питон из
зависимостей репозиториев-потребителей.
Связано с вопросом 2: если тулинг всё равно переписывается, разделение
«целостность канона / установка в проект» дешевле заложить сразу, чем
отпиливать потом.
## 11. Ссылки на родительский слой своей темы
Из `lang/` и `stack/` ссылаться на идентификаторы своего же арх-слоя
безопасно: при сборке они оказываются секциями одного файла, и ссылка
никуда не ведёт — правило рядом. Ограничение META-20 писалось именно про
**чужую тему**, так что формально это уже разрешено.
Но стоит проговорить явно, потому что сейчас читается уже как запрет:
- в `LANGUAGE.md` проверка сформулирована как «**чужой префикс** не
встречается в абзаце с модальностью» — по букве это ловит и `GTIM`
`TIME-3`, то есть ложно срабатывает на ровно том случае, который
разрешён. Должно быть «префикс **чужой темы**»;
- обсудить, не добавить ли в META-20 явную строку **ДОПУСКАЕТСЯ** про
родительский слой: правило, которое читают как более строгое, чем оно
есть, заставляет авторов дублировать текст без нужды.
## Из вчерашнего, не закрыто
- Вынос арх-ядра из `errors` и `logging`: закроет две хрупкие ссылки из
`web-ui` (стек) в go-слой — единственные ссылки стек → язык в каноне.
- `conv check` должен уметь отличать ссылку на удалённое правило от
упоминания дыры: сейчас `META-16` в прозе «Оформления» даёт ложное
срабатывание.
-516
View File
@@ -1,516 +0,0 @@
#!/usr/bin/env python3
"""conv — синхронизация конвенций между каноном и репозиторием.
Канон — директория conventions/ рядом с этим скриптом. Репозиторий держит
закоммиченные копии нужных конвенций в docs/conventions/, повторяя её
структуру. Копия — источник правды для репозитория; канон — лавка, из
которой берут. Пути в origin даются относительно conventions/.
Служебная разметка копии:
---
origin: arch/time.md # откуда взято
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
synced: 2026-07-25
local: нет # или текст: чем и почему разошлись
---
Прочие ключи шапки (status, extends) — часть документа: они сравниваются
наравне с телом и приезжают из канона.
Локальные регионы — куски, которые по определению принадлежат репозиторию
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:
<!-- local:механизировано -->
...
<!-- /local -->
Имя региона обязательно: перенос при pull идёт по именам.
Команды:
conv list что есть в каноне
conv add arch/time.md [...] взять конвенцию в репозиторий
conv status состояние копий репозитория
conv diff [arch/time.md] чем копия отличается от канона
conv pull arch/time.md забрать обновление канона
conv push arch/time.md вернуть локальное улучшение в канон
conv push --new lang/go/x.md завести в каноне новую конвенцию
Везде можно указать --repo <path> (по умолчанию — текущая директория)
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
"""
from __future__ import annotations
import argparse
import datetime
import difflib
import hashlib
import re
import sys
from pathlib import Path
from typing import NoReturn
CANON = Path(__file__).resolve().parent / "conventions"
CANON_TREES = ("arch", "lang", "stack")
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
DEFAULT_DIR = "docs/conventions"
ENC = "utf-8"
# Маркеры распознаются только в начале строки: так пример разметки внутри
# текста конвенции не превращается в настоящий регион.
REGION_RE = re.compile(
r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
re.DOTALL | re.MULTILINE,
)
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)
def die(message: str) -> NoReturn:
print(f"conv: {message}", file=sys.stderr)
sys.exit(1)
# --- разметка --------------------------------------------------------------
def read(path: Path) -> str:
return path.read_text(encoding=ENC)
def write(path: Path, text: str) -> None:
path.write_text(text, encoding=ENC)
def split_front(text: str) -> tuple[dict[str, str], str]:
"""Отделяет YAML-шапку (плоский key: value) от тела."""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
meta: dict[str, str] = {}
for line in text[4:end].splitlines():
if ":" in line:
key, value = line.split(":", 1)
meta[key.strip()] = value.strip()
return meta, text[end + 5 :]
def join_front(meta: dict[str, str], body: str) -> str:
if not meta:
return body
lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
return f"---\n{lines}\n---\n{body}"
def doc_keys(meta: dict[str, str]) -> dict[str, str]:
return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}
def regions(body: str) -> dict[str, str]:
"""Содержимое локальных регионов по имени.
Поднимает ValueError на разметке, из-за которой регион молча превратился
бы в обычный текст и потерялся при pull.
"""
matched = len(REGION_RE.findall(body))
if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
raise ValueError("непарный или нераспознанный маркер локального региона")
found: dict[str, str] = {}
for match in REGION_RE.finditer(body):
name = (match.group(1) or "").strip()
content = match.group(2)
if not name:
if content.strip():
raise ValueError(
"безымянный локальный регион с содержимым — дай ему имя"
)
continue
if name in found:
raise ValueError(f"локальный регион '{name}' встречается дважды")
found[name] = content
return found
def checked_regions(body: str, where: str) -> dict[str, str]:
try:
return regions(body)
except ValueError as exc:
die(f"{where}: {exc}")
def blank_regions(body: str) -> str:
"""Тело с опустошёнными локальными регионами — то, что сравнивается."""
def repl(match: re.Match[str]) -> str:
raw = (match.group(1) or "").strip()
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
return f"{head}\n<!-- /local -->"
return REGION_RE.sub(repl, body)
def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
"""Вставляет содержимое регионов по имени. Возвращает тело и имена,
которым не нашлось места."""
used: set[str] = set()
def repl(match: re.Match[str]) -> str:
raw = (match.group(1) or "").strip()
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
if raw in values:
used.add(raw)
return f"{head}{values[raw]}<!-- /local -->"
return match.group(0)
filled = REGION_RE.sub(repl, body)
lost = [n for n, v in values.items() if n not in used and v.strip()]
return filled, lost
def fingerprint(meta: dict[str, str], body: str) -> str:
"""Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
:8
]
def today() -> str:
return datetime.date.today().isoformat()
# --- канон и репозиторий ---------------------------------------------------
def canon_list() -> list[str]:
out: list[str] = []
for tree in CANON_TREES:
root = CANON / tree
if root.is_dir():
out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
return out
def canon_read(origin: str) -> tuple[dict[str, str], str]:
path = CANON / origin
if not path.is_file():
die(f"в каноне нет {origin}")
return split_front(read(path))
def normalize_origin(name: str, *, must_exist: bool = True) -> str:
"""Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
name = name.strip("/")
if not name.endswith(".md"):
name += ".md"
candidate = (CANON / name).resolve()
if candidate.is_relative_to(CANON):
rel = str(candidate.relative_to(CANON))
if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
return rel
matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
if len(matches) == 1:
return matches[0]
if not matches:
die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
die(f"неоднозначно: {name} → {', '.join(matches)}")
def repo_dir(args: argparse.Namespace) -> Path:
return (Path(str(args.repo)) / str(args.dir)).resolve()
def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
"""origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
found: dict[str, Path] = {}
untracked: list[Path] = []
problems: list[str] = []
if not base.is_dir():
return found, untracked, problems
for path in sorted(base.rglob("*.md")):
try:
meta, _ = split_front(read(path))
except (OSError, UnicodeDecodeError) as exc:
problems.append(f"{path.name}: не читается ({type(exc).__name__})")
continue
origin = meta.get("origin")
if not origin:
if path.name != "README.md":
untracked.append(path)
continue
if origin in found:
problems.append(
f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
)
continue
found[origin] = path
return found, untracked, problems
def locate(args: argparse.Namespace, origin: str) -> Path:
"""Путь копии: по шапке, если она лежит не по канонному пути."""
base = repo_dir(args)
copies, _, _ = repo_copies(base)
return copies.get(origin, base / origin)
# --- состояние -------------------------------------------------------------
def state(
meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
) -> str:
copy_fp = fingerprint(meta, body)
canon_fp = fingerprint(canon_meta, canon_body)
if copy_fp == canon_fp:
return "ok"
base = meta.get("origin_hash")
if not base:
return "нет origin_hash в шапке"
if base == canon_fp:
return "изменено локально"
if base == copy_fp:
return "канон обновился"
return "разошлись"
# --- команды ---------------------------------------------------------------
def cmd_list(args: argparse.Namespace) -> int:
for origin in canon_list():
meta, _ = canon_read(origin)
marks = []
if "extends" in meta:
marks.append(f"расширяет {meta['extends']}")
if "status" in meta:
marks.append(meta["status"])
tail = f" ({'; '.join(marks)})" if marks else ""
print(f"{origin}{tail}")
return 0
def cmd_add(args: argparse.Namespace) -> int:
base = repo_dir(args)
added = False
for raw in args.names:
origin = normalize_origin(raw)
target = base / origin
if target.exists():
print(f"{origin}: уже есть ({target}), пропускаю")
continue
canon_meta, canon_body = canon_read(origin)
checked_regions(canon_body, f"канон/{origin}")
meta: dict[str, str] = {
"origin": origin,
"origin_hash": fingerprint(canon_meta, canon_body),
"synced": today(),
"local": "нет",
}
meta.update(doc_keys(canon_meta))
target.parent.mkdir(parents=True, exist_ok=True)
write(target, join_front(meta, canon_body))
added = True
print(f"{origin} → {target}")
if "extends" in canon_meta:
print(f" расширяет {canon_meta['extends']} — возможно, нужна и она")
if added:
print("не забудь строку в docs/conventions/README.md")
return 0
def cmd_status(args: argparse.Namespace) -> int:
base = repo_dir(args)
copies, untracked, problems = repo_copies(base)
if not copies and not untracked and not problems:
print(f"в {base} нет копий конвенций")
return 0
width = max((len(o) for o in copies), default=0)
for origin, path in copies.items():
try:
meta, body = split_front(read(path))
except (OSError, UnicodeDecodeError) as exc:
print(f"{origin:<{width}} не читается ({type(exc).__name__})")
continue
if not (CANON / origin).is_file():
print(f"{origin:<{width}} нет в каноне")
continue
canon_meta, canon_body = canon_read(origin)
try:
regions(body)
except ValueError as exc:
print(f"{origin:<{width}} разметка: {exc}")
continue
local = meta.get("local", "нет")
note = "" if local == "нет" else f" [{local}]"
print(f"{origin:<{width}} {state(meta, body, canon_meta, canon_body)}{note}")
for path in untracked:
print(f"{path.name}: без шапки origin — не отслеживается")
for problem in problems:
print(problem)
return 0
def cmd_diff(args: argparse.Namespace) -> int:
base = repo_dir(args)
copies, _, _ = repo_copies(base)
targets = [normalize_origin(args.name)] if args.name else list(copies)
for origin in targets:
path = copies.get(origin)
if path is None:
print(f"{origin}: нет копии в репозитории")
continue
if not (CANON / origin).is_file():
print(f"{origin}: нет в каноне")
continue
meta, body = split_front(read(path))
canon_meta, canon_body = canon_read(origin)
if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
continue
sys.stdout.writelines(
difflib.unified_diff(
join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
keepends=True
),
join_front(doc_keys(meta), blank_regions(body)).splitlines(
keepends=True
),
fromfile=f"канон/{origin}",
tofile=f"репо/{origin}",
)
)
return 0
def cmd_pull(args: argparse.Namespace) -> int:
origin = normalize_origin(args.name)
path = locate(args, origin)
if not path.is_file():
die(f"нет копии {origin} — сначала conv add {origin}")
meta, body = split_front(read(path))
canon_meta, canon_body = canon_read(origin)
checked_regions(canon_body, f"канон/{origin}")
local = checked_regions(body, f"репо/{origin}")
st = state(meta, body, canon_meta, canon_body)
if st == "ok":
fresh = fingerprint(canon_meta, canon_body)
if meta.get("origin_hash") != fresh:
meta["origin_hash"] = fresh
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: тексты совпадают, отпечаток освежён")
else:
print(f"{origin}: уже совпадает")
return 0
if st in ("изменено локально", "разошлись") and not args.force:
die(
f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
f" посмотри conv diff {origin}, затем conv pull --force "
f"или conv push {origin}"
)
merged, lost = fill_regions(canon_body, local)
if lost and not args.force:
die(
f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
f"пропадёт.\n перенеси вручную или conv pull --force"
)
for name in lost:
print(f" потерян локальный регион {name}")
new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
new_meta["synced"] = today()
new_meta.update(doc_keys(canon_meta))
write(path, join_front(new_meta, merged))
print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
return 0
def cmd_push(args: argparse.Namespace) -> int:
origin = normalize_origin(args.name, must_exist=not args.new)
path = locate(args, origin)
if not path.is_file():
die(f"нет копии {origin}")
meta, body = split_front(read(path))
checked_regions(body, f"репо/{origin}")
target = CANON / origin
if not target.is_file():
if not args.new:
die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
target.parent.mkdir(parents=True, exist_ok=True)
write(target, join_front(doc_keys(meta), blank_regions(body)))
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: заведена в каноне")
return 0
canon_meta, canon_body = canon_read(origin)
st = state(meta, body, canon_meta, canon_body)
if st == "ok":
print(f"{origin}: канон уже такой")
return 0
if st == "канон обновился":
die(
f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
)
if st == "разошлись" and not args.force:
die(
f"{origin}: разошлись — канон менялся после синхронизации, "
f"его правки затрутся.\n посмотри conv diff {origin}, "
f"затем conv push --force"
)
write(target, join_front(doc_keys(meta), blank_regions(body)))
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
meta["synced"] = today()
write(path, join_front(meta, body))
print(f"{origin}: канон обновлён из репозитория")
return 0
def main() -> int:
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--repo", default=".", help="корень репозитория")
common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")
parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
fn=cmd_list
)
p_add = sub.add_parser(
"add", parents=[common], help="взять конвенцию в репозиторий"
)
p_add.add_argument("names", nargs="+")
p_add.set_defaults(fn=cmd_add)
sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
fn=cmd_status
)
p_diff = sub.add_parser(
"diff", parents=[common], help="чем копия отличается от канона"
)
p_diff.add_argument("name", nargs="?")
p_diff.set_defaults(fn=cmd_diff)
p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
p_pull.add_argument("name")
p_pull.add_argument("--force", action="store_true")
p_pull.set_defaults(fn=cmd_pull)
p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
p_push.add_argument("name")
p_push.add_argument("--force", action="store_true")
p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
p_push.set_defaults(fn=cmd_push)
args = parser.parse_args()
return int(args.fn(args))
if __name__ == "__main__":
sys.exit(main())
+15 -21
View File
@@ -1,4 +1,5 @@
--- ---
topic: app-directories
prefix: DIRS prefix: DIRS
--- ---
@@ -8,7 +9,11 @@ prefix: DIRS
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
отвечает на два вопроса, которые иначе выясняются чтением кода приложения: отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий **кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
механически выводится состав бэкапа. Форма записи — `LANGUAGE.md`. механически выводится состав бэкапа.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -33,7 +38,7 @@ prefix: DIRS
Имена в таблице — умолчание для случая «одна директория на категорию». Имена в таблице — умолчание для случая «одна директория на категорию».
**Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно **ПОЧЕМУ.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно
снести при нехватке места, что переживает переезд на другой диск — снести при нехватке места, что переживает переезд на другой диск —
читаются из категории, а не выясняются по коду приложения. Без единой читаются из категории, а не выясняются по коду приложения. Без единой
классификации каждое такое решение принимается заново и каждый раз чуть классификации каждое такое решение принимается заново и каждый раз чуть
@@ -45,7 +50,7 @@ prefix: DIRS
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; **ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
принадлежность к категории задаётся не именем, а участием в списке бэкапа. принадлежность к категории задаётся не именем, а участием в списке бэкапа.
**Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно **ПОЧЕМУ.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно
три директории». Крупные файлы отделяют от базы, чтобы двигать их между три директории». Крупные файлы отделяют от базы, чтобы двигать их между
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном `data/`); запрет на такое деление вынуждал бы либо держать всё на одном
@@ -63,7 +68,7 @@ prefix: DIRS
| DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | | DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | | DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
**Почему.** Без внешнего теста граница проводится по ощущению «жалко **ПОЧЕМУ.** Без внешнего теста граница проводится по ощущению «жалко
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и
разделяет эти два свойства именно способность приложения пересоздать разделяет эти два свойства именно способность приложения пересоздать
@@ -76,7 +81,7 @@ prefix: DIRS
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и **ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
кеш — нет. кеш — нет.
**Почему.** Кеш раздувает снапшот содержимым, которое приложение **ПОЧЕМУ.** Кеш раздувает снапшот содержимым, которое приложение
восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там
лежат секреты, а бэкапы уезжают в облако — источник истины для лежат секреты, а бэкапы уезжают в облако — источник истины для
конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте. конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте.
@@ -93,7 +98,7 @@ prefix: DIRS
буквально: переменная деплоя, константа, поле конфигурации. Всё остальное буквально: переменная деплоя, константа, поле конфигурации. Всё остальное
на него ссылается. на него ссылается.
**Почему.** Правило вывода механическое, но применяет его человек или **ПОЧЕМУ.** Правило вывода механическое, но применяет его человек или
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
невозможным: переименование директории отражается в обоих местах сразу. невозможным: переименование директории отражается в обоих местах сразу.
Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа
@@ -109,7 +114,7 @@ prefix: DIRS
| DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием | | DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием |
| DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | | DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
**Почему.** Файловый снапшот работающей СУБД не гарантирует **ПОЧЕМУ.** Файловый снапшот работающей СУБД не гарантирует
консистентности: скопированный каталог может не восстановиться, и узнают консистентности: скопированный каталог может не восстановиться, и узнают
об этом при восстановлении. Директория дампов — тоже данные, просто об этом при восстановлении. Директория дампов — тоже данные, просто
производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из
@@ -121,7 +126,7 @@ prefix: DIRS
**ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда **ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда
приложение заводят. приложение заводят.
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не **ПОЧЕМУ.** Неверный выбор ничем себя не проявляет, пока бэкап не
понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из
которых база не поднимется. Отложить решение — значит принять его по факту которых база не поднимется. Отложить решение — значит принять его по факту
первой неудачной попытки восстановления, то есть тогда, когда данных уже первой неудачной попытки восстановления, то есть тогда, когда данных уже
@@ -132,7 +137,7 @@ prefix: DIRS
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для **ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
кеша, а не один каталог на всё. кеша, а не один каталог на всё.
**Почему.** Снаружи категория определяется только тогда, когда разным **ПОЧЕМУ.** Снаружи категория определяется только тогда, когда разным
категориям соответствуют разные директории. Всё, сложенное в один каталог, категориям соответствуют разные директории. Всё, сложенное в один каталог,
заставляет составлять список бэкапа вручную, читая код приложения, — и заставляет составлять список бэкапа вручную, читая код приложения, — и
пересматривать его при каждом обновлении, потому что новый подкаталог пересматривать его при каждом обновлении, потому что новый подкаталог
@@ -144,19 +149,8 @@ prefix: DIRS
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь **НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
конфигурации. конфигурации.
**Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому **ПОЧЕМУ.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому
всё, что приложение туда записало, следующий деплой затирает без всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
видно в момент, когда приложение настраивают. видно в момент, когда приложение настраивают.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:эталон -->
<!-- /local -->
<!-- local:связано -->
<!-- /local -->
+27 -30
View File
@@ -1,11 +1,16 @@
--- ---
topic: config
prefix: CONF prefix: CONF
--- ---
# Конфигурация приложения # Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает. Форма записи — `LANGUAGE.md`. секретами и когда падает.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -21,7 +26,7 @@ prefix: CONF
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат. окружения источником конфигурации не служат.
**Почему.** Три довода, по убыванию веса: **ПОЧЕМУ.** Три довода, по убыванию веса:
- **Один типизированный источник.** Файл несёт секции, комментарии, - **Один типизированный источник.** Файл несёт секции, комментарии,
единицы измерения и валидируется целиком. Окружение — плоский набор единицы измерения и валидируется целиком. Окружение — плоский набор
@@ -44,7 +49,7 @@ prefix: CONF
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
**Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг **ПОЧЕМУ.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает CONF-9 вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же плоский список пар такой возможности не даёт и возвращает нас к тем же
@@ -55,7 +60,7 @@ prefix: CONF
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки. задаётся опцией командной строки.
**Почему.** Запуск без аргументов работает одинаково в разработке, в **ПОЧЕМУ.** Запуск без аргументов работает одинаково в разработке, в
контейнере и на сервере, и способ запуска не приходится помнить отдельно контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем (тесты, второй инстанс): без неё их разводят переменной окружения — тем
@@ -67,7 +72,7 @@ prefix: CONF
рабочей директории (CONF-3), приложение не стартует: сообщение называет рабочей директории (CONF-3), приложение не стартует: сообщение называет
искомый путь, код возврата ненулевой. искомый путь, код возврата ненулевой.
**Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что **ПОЧЕМУ.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
развёртывание не довело работу до конца, а не что приложение попросили развёртывание не довело работу до конца, а не что приложение попросили
работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается **неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
@@ -81,13 +86,11 @@ prefix: CONF
Приложение, которое запускается вообще без конфигурации, этой конвенцией не Приложение, которое запускается вообще без конфигурации, этой конвенцией не
описывается: это отдельный случай и отдельная конвенция. описывается: это отдельный случай и отдельная конвенция.
<!-- local:проверки -->
<!-- /local -->
### CONF-4. В репозитории лежит образец, а не рабочий конфиг ### CONF-4. В репозитории лежит образец, а не рабочий конфиг
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет, **ПОЧЕМУ.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того, попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины: закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча. он расходится с тем, что реально развёрнуто, и расходится молча.
@@ -97,7 +100,7 @@ prefix: CONF
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет. файла конфигурации в бизнес-коде нет.
**Почему.** Второе место чтения — это второй момент времени: две части кода **ПОЧЕМУ.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали. воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17), Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
@@ -107,7 +110,7 @@ prefix: CONF
**ДОЛЖЕН.** Смена параметров — рестарт процесса. **ДОЛЖЕН.** Смена параметров — рестарт процесса.
**Почему.** Изменяемый конфиг делает поведение функцией момента: один **ПОЧЕМУ.** Изменяемый конфиг делает поведение функцией момента: один
запрос обслуживается наполовину старыми, наполовину новыми значениями, а запрос обслуживается наполовину старыми, наполовину новыми значениями, а
разбор инцидента требует знать хронологию правок файла, а не его текущее разбор инцидента требует знать хронологию правок файла, а не его текущее
содержимое. содержимое.
@@ -119,7 +122,7 @@ prefix: CONF
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
**Почему.** Умолчание, живущее в образце, действует только для тех, кто **ПОЧЕМУ.** Умолчание, живущее в образце, действует только для тех, кто
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется. поведение для неполного конфига и одно место, где это значение меняется.
@@ -129,7 +132,7 @@ prefix: CONF
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (CONF-7). которых есть умолчание (CONF-7).
**Почему.** Поле, живущее только в коде, для читателя конфига не **ПОЧЕМУ.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой CONF-7 нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
покупает себе видимость. покупает себе видимость.
@@ -143,7 +146,7 @@ prefix: CONF
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля - **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`. `01`.
**Почему.** Так конфиг читается без открывания кода — этим он и полезен; **ПОЧЕМУ.** Так конфиг читается без открывания кода — этим он и полезен;
без комментария читатель всё равно идёт в код, и образец перестаёт быть без комментария читатель всё равно идёт в код, и образец перестаёт быть
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
дают валидное значение и работающий процесс, а ошибка обнаруживается по дают валидное значение и работающий процесс, а ошибка обнаруживается по
@@ -159,7 +162,7 @@ prefix: CONF
| CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | | CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | | CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
**Почему.** Фиксированный на секцию набор обязательных полей оставляет **ПОЧЕМУ.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там, не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
@@ -172,7 +175,7 @@ prefix: CONF
альтернативные — блоками-комментариями ниже, каждый со своим описанием альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей. полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец **ПОЧЕМУ.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации. на месте, а не сборкой секции с нуля по документации.
@@ -182,7 +185,7 @@ prefix: CONF
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет. отдельного слоя секретов в приложении нет.
**Почему.** Источник истины секрета — внешнее хранилище деплоя, не **ПОЧЕМУ.** Источник истины секрета — внешнее хранилище деплоя, не
репозиторий и не окружение. Любой второй канал — переменная окружения рядом репозиторий и не окружение. Любой второй канал — переменная окружения рядом
с файлом, собственный клиент к хранилищу внутри приложения — возвращает с файлом, собственный клиент к хранилищу внутри приложения — возвращает
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
@@ -194,7 +197,7 @@ prefix: CONF
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс. работает процесс.
**Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная **ПОЧЕМУ.** После CONF-1 и CONF-12 файл конфигурации — единственная
поверхность, на которой секреты лежат, и весь довод «файл вместо окружения» поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
@@ -205,7 +208,7 @@ prefix: CONF
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример. пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее **ПОЧЕМУ.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного. механически отличимым от заполненного.
@@ -214,7 +217,7 @@ prefix: CONF
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится **ПОЧЕМУ.** Это ловит криво отрендеренный шаблон до того, как он превратится
в 401 от внешнего API через час работы, — то есть в момент, когда причина в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем. ещё очевидна и связана с деплоем.
@@ -223,21 +226,18 @@ prefix: CONF
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне. одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они **ПОЧЕМУ.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при записи. Типичный источник утечки — отладочный дамп разобранного конфига при
старте. старте.
<!-- local:секретные-поля -->
<!-- /local -->
### CONF-17. Конфиг валидируется на старте, до приёма трафика ### CONF-17. Конфиг валидируется на старте, до приёма трафика
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину». кодом; процесс не стартует «наполовину».
**Почему.** Наполовину стартовавший процесс проходит проверку живости и **ПОЧЕМУ.** Наполовину стартовавший процесс проходит проверку живости и
падает позже — на первом запросе, который трогает испорченный параметр, — и падает позже — на первом запросе, который трогает испорченный параметр, — и
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен, падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
@@ -255,7 +255,7 @@ prefix: CONF
| CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | | CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
| CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | | CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый **ПОЧЕМУ.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению. сводит их все к одному моменту и одному сообщению.
@@ -265,7 +265,7 @@ prefix: CONF
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой. списком, а не падает на первой.
**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько **ПОЧЕМУ.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
одного источника: разом они читаются как одна причина, по одной — как одного источника: разом они читаются как одна причина, по одной — как
@@ -281,7 +281,7 @@ prefix: CONF
| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | | CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
| CONF-21.2 | секретное | имя поля и суть нарушения, без значения | | CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
**Почему.** Сообщение без значения отправляет читателя в файл — сличать **ПОЧЕМУ.** Сообщение без значения отправляет читателя в файл — сличать
глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд» глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
или пробел в конце значения из такого сообщения не читаются вовсе. Значение или пробел в конце значения из такого сообщения не читаются вовсе. Значение
секретного поля при этом печатать некуда: вывод старта уходит в лог секретного поля при этом печатать некуда: вывод старта уходит в лог
@@ -297,6 +297,3 @@ prefix: CONF
конфигурируемый параметр времени, семантика описана там. конфигурируемый параметр времени, семантика описана там.
- конвенция `app-directories` — конфиг лежит в категории «конфигурация» и - конвенция `app-directories` — конфиг лежит в категории «конфигурация» и
доступен приложению только на чтение. доступен приложению только на чтение.
<!-- local:связано -->
<!-- /local -->
+13 -15
View File
@@ -1,11 +1,15 @@
--- ---
topic: db-identifiers
prefix: KEYS prefix: KEYS
--- ---
# Идентификаторы сущностей # Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей. Форма записи — Как выбираются и как выглядят первичные ключи сущностей.
`LANGUAGE.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -24,7 +28,7 @@ prefix: KEYS
который порождает приложение, — во **всех** таблицах, включая те, что который порождает приложение, — во **всех** таблицах, включая те, что
снаружи не адресуются. снаружи не адресуются.
**Почему.** Ветвления здесь нет намеренно, хотя напрашивается: «эту **ПОЧЕМУ.** Ветвления здесь нет намеренно, хотя напрашивается: «эту
сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности
имеют привычку становиться внешними — и тогда целочисленный идентификатор имеют привычку становиться внешними — и тогда целочисленный идентификатор
утекает в URL задним числом, а миграция ключа на живых данных стоит утекает в URL задним числом, а миграция ключа на живых данных стоит
@@ -44,7 +48,7 @@ prefix: KEYS
**ДОЛЖЕН.** Значение ключа известно до вставки строки. **ДОЛЖЕН.** Значение ключа известно до вставки строки.
**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог **ПОЧЕМУ.** Идентификатор нужен раньше, чем база ответит: его пишут в лог
начатой операции, кладут в связанные записи одной транзакции и возвращают начатой операции, кладут в связанные записи одной транзакции и возвращают
клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid` клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid`
и достраивать связи вторым проходом, либо иметь два источника истины о и достраивать связи вторым проходом, либо иметь два источника истины о
@@ -55,7 +59,7 @@ prefix: KEYS
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает. **ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет. Самодельных генераторов и парсеров в коде нет.
**Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны **ПОЧЕМУ.** Нормализация регистра (KEYS-4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан. проявится не там, где создан.
@@ -64,7 +68,7 @@ prefix: KEYS
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре. **ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не **ПОЧЕМУ.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний** косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3) регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3)
разный регистр появится в базе сам собой. разный регистр появится в базе сам собой.
@@ -80,7 +84,7 @@ prefix: KEYS
| KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу | | KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | | KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать **ПОЧЕМУ.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика. границе, мы дёшево снимаем целый класс мусорного трафика.
@@ -103,7 +107,7 @@ prefix: KEYS
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный **ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится. сгенерированный идентификатор не заводится.
**Почему.** Суррогат поверх естественного ключа создаёт второй способ **ПОЧЕМУ.** Суррогат поверх естественного ключа создаёт второй способ
адресовать ту же строку — а значит, возможность рассинхрона между ними и адресовать ту же строку — а значит, возможность рассинхрона между ними и
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт. информации он не несёт.
@@ -114,7 +118,7 @@ prefix: KEYS
задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же
формате. формате.
**Почему.** Единый формат делает работающим главный побочный эффект **ПОЧЕМУ.** Единый формат делает работающим главный побочный эффект
строковых идентификаторов: `grep` по голому значению собирает все строковых идентификаторов: `grep` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она идентификаторов эту возможность отменяет ровно для тех записей, где она
@@ -131,12 +135,6 @@ KEYS-1 требует **сортируемый** строковый иденти
внутри одной миллисекунды порядок произволен, если генератор не монотонный, внутри одной миллисекунды порядок произволен, если генератор не монотонный,
— на порядок событий это не влияет. — на порядок событий это не влияет.
<!-- local:отступления -->
<!-- /local -->
## Связано ## Связано
- конвенция `time` — метки времени тоже генерирует приложение, а не схема. - конвенция `time` — метки времени тоже генерирует приложение, а не схема.
<!-- local:связано -->
<!-- /local -->
+19 -24
View File
@@ -1,12 +1,16 @@
--- ---
topic: time
prefix: TIME prefix: TIME
--- ---
# Время # Время
Как приложение записывает моменты и длительности: в каком формате, откуда Как приложение записывает моменты и длительности: в каком формате, откуда
берётся значение и где появляется не-UTC. Форма записи — берётся значение и где появляется не-UTC.
`LANGUAGE.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -23,7 +27,7 @@ prefix: TIME
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами. одинаково в хранении, логах, API и обмене с внешними системами.
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой **ПОЧЕМУ.** Разные форматы в разных слоях требуют преобразования на каждой
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
полгода, на переходе на летнее время, когда реальное смещение перестаёт полгода, на переходе на летнее время, когда реальное смещение перестаёт
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
@@ -34,7 +38,7 @@ prefix: TIME
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает. строки времени одна и от записи к записи не плавает.
**Почему.** Лексикографическая сортировка совпадает с хронологией только **ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только
среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по
текстовому полю обязан давать порядок событий. Плавающая ширина (типичный текстовому полю обязан давать порядок событий. Плавающая ширина (типичный
@@ -46,7 +50,7 @@ prefix: TIME
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
**Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что **ПОЧЕМУ.** Квантор в TIME-2 — на носитель, а не на приложение, потому что
строки разных носителей между собой не сравниваются: сортировка идёт внутри строки разных носителей между собой не сравниваются: сортировка идёт внутри
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался
как «одна точность на всё приложение»: от подгонки формата логов под формат как «одна точность на всё приложение»: от подгонки формата логов под формат
@@ -58,7 +62,7 @@ prefix: TIME
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
зоне. зоне.
**Почему.** Метка без зоны неинтерпретируема вне процесса, который её **ПОЧЕМУ.** Метка без зоны неинтерпретируема вне процесса, который её
записал: чтобы понять, какому моменту она соответствует, читателю нужно записал: чтобы понять, какому моменту она соответствует, читателю нужно
знать настройки чужой машины на момент записи. И даже зная их, он не знать настройки чужой машины на момент записи. И даже зная их, он не
разберёт час перехода на зимнее время: этот час идёт дважды, две записи разберёт час перехода на зимнее время: этот час идёт дважды, две записи
@@ -70,7 +74,7 @@ prefix: TIME
долями секунды принимается от внешней системы и приводится к каноническому долями секунды принимается от внешней системы и приводится к каноническому
виду (TIME-1) в точке разбора (TIME-5). виду (TIME-1) в точке разбора (TIME-5).
**Почему.** Канонический вид — обязательство нашего писателя, а не **ПОЧЕМУ.** Канонический вид — обязательство нашего писателя, а не
контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же
момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со
стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение
@@ -85,7 +89,7 @@ prefix: TIME
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
метки; прямые вызовы часов по коду не разбросаны. метки; прямые вызовы часов по коду не разбросаны.
**Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех **ПОЧЕМУ.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех
меток без исключения, а каждый прямой вызов часов заводит ещё одно место, меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
данных, и обнаруживается, когда испорченных записей уже накопилось. данных, и обнаруживается, когда испорченных записей уже накопилось.
@@ -95,7 +99,7 @@ prefix: TIME
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий **ПОЧЕМУ.** Дефолт превращает забытую вставку `created_at` в тихо работающий
код: значение появляется, но приходит от сервера БД — то есть с других часов код: значение появляется, но приходит от сервера БД — то есть с других часов
и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка
падает громко и чинится в момент написания, а не при разборе расхождения падает громко и чинится в момент написания, а не при разборе расхождения
@@ -107,7 +111,7 @@ prefix: TIME
**ДОЛЖЕН.** Длительность операции записывается числом (обычно **ДОЛЖЕН.** Длительность операции записывается числом (обычно
миллисекундами) в поле вида `duration_ms`. миллисекундами) в поле вида `duration_ms`.
**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько». **ПОЧЕМУ.** Метка отвечает на вопрос «когда», длительность — на «сколько».
Пара меток заставляет каждого потребителя знать, какие именно две из них Пара меток заставляет каждого потребителя знать, какие именно две из них
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
логе; число сравнивается, агрегируется и попадает в перцентили без этого логе; число сравнивается, агрегируется и попадает в перцентили без этого
@@ -118,7 +122,7 @@ prefix: TIME
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
**Почему.** Обе границы операции видит только этот слой: замер уровнем выше **ПОЧЕМУ.** Обе границы операции видит только этот слой: замер уровнем выше
приписывает операции чужие накладные расходы, уровнем ниже — теряет часть приписывает операции чужие накладные расходы, уровнем ниже — теряет часть
вызова. В обоих случаях число остаётся правдоподобным и потому не вызова. В обоих случаях число остаётся правдоподобным и потому не
оспаривается, хотя отвечает не на тот вопрос, который к нему задают. оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
@@ -132,7 +136,7 @@ prefix: TIME
| TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) | | TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) |
| TIME-9.2 | длительность операции | монотонные часы процесса | | TIME-9.2 | длительность операции | монотонные часы процесса |
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда **ПОЧЕМУ.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для
меток: их ноль произволен и не переживает перезапуск процесса, так что вне меток: их ноль произволен и не переживает перезапуск процесса, так что вне
@@ -145,7 +149,7 @@ prefix: TIME
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
проникает в хранение, сортировку и логи. проникает в хранение, сортировку и логи.
**Почему.** Как только конвертация уходит вглубь, результат вычислений **ПОЧЕМУ.** Как только конвертация уходит вглубь, результат вычислений
начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт
разные группировки, а порядок записей перестаёт быть общим для всех. Ещё разные группировки, а порядок записей перестаёт быть общим для всех. Ещё
хуже, что при конвертации в нескольких слоях её легко выполнить дважды — хуже, что при конвертации в нескольких слоях её легко выполнить дважды —
@@ -157,7 +161,7 @@ prefix: TIME
**ДОЛЖЕН.** Значение приходит из конфигурации, значение по умолчанию — **ДОЛЖЕН.** Значение приходит из конфигурации, значение по умолчанию —
`UTC`. `UTC`.
**Почему.** Зашитая в код зона превращает переезд или второго пользователя в **ПОЧЕМУ.** Зашитая в код зона превращает переезд или второго пользователя в
другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано
потому, что оно не притворяется настроенным: показанное время совпадает с потому, что оно не притворяется настроенным: показанное время совпадает с
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
@@ -168,7 +172,7 @@ prefix: TIME
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса. явно переданной зоной, а не с системной зоной процесса.
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на **ПОЧЕМУ.** Системная зона разная на ноутбуке разработчика и в контейнере на
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта: сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
расхождение не воспроизводится там, где его заметили, и объясняется средой, расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов. а не кодом. Явно переданная зона делает результат функцией от аргументов.
@@ -176,17 +180,8 @@ prefix: TIME
Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная
логика, которой нужна другая, получает её тем же явным аргументом. логика, которой нужна другая, получает её тем же явным аргументом.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
## Связано ## Связано
- конвенция `config` — где задаётся зона отображения. - конвенция `config` — где задаётся зона отображения.
- конвенция `db-identifiers` — то же правило «генерирует приложение» для - конвенция `db-identifiers` — то же правило «генерирует приложение» для
идентификаторов. идентификаторов.
<!-- local:связано -->
<!-- /local -->
+22 -25
View File
@@ -1,12 +1,18 @@
--- ---
topic: config
prefix: GCFG prefix: GCFG
lang: go
extends: arch/config.md extends: arch/config.md
--- ---
# Конфигурация: реализация на Go # Конфигурация: реализация на Go
Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы
запрета на окружение. Форма записи — `LANGUAGE.md`. запрета на окружение.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
проверка их непустоты идёт вместе с остальной валидацией — как описано в проверка их непустоты идёт вместе с остальной валидацией — как описано в
@@ -18,7 +24,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Конфиг — файл TOML. **ДОЛЖЕН.** Конфиг — файл TOML.
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор **ПОЧЕМУ.** Базовая конвенция оставляет формат за стеком, и этот выбор
делается один раз на язык, а не в каждом приложении: разные форматы в делается один раз на язык, а не в каждом приложении: разные форматы в
соседних сервисах означают разные загрузчики, разные шаблоны рендера соседних сервисах означают разные загрузчики, разные шаблоны рендера
конфига в деплое и разное поведение при синтаксической ошибке. TOML при конфига в деплое и разное поведение при синтаксической ошибке. TOML при
@@ -31,7 +37,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `Config`. `internal/config`; наружу пакет отдаёт готовую структуру `Config`.
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным; **ПОЧЕМУ.** Пока значение не покинуло пакет, оно может быть невалидным;
после — уже нет, и это единственная граница, на которой такое утверждение после — уже нет, и это единственная граница, на которой такое утверждение
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
«проверено ли это поле» только чтением всех вызывающих, часть полей «проверено ли это поле» только чтением всех вызывающих, часть полей
@@ -44,7 +50,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям. под-структур по секциям.
**Почему.** Один корень даёт одну точку, после которой конфиг проверен **ПОЧЕМУ.** Один корень даёт одну точку, после которой конфиг проверен
целиком, и дальше передаётся как обычный аргумент. Несколько независимых целиком, и дальше передаётся как обычный аргумент. Несколько независимых
структур конфига означают несколько загрузок и вопрос «какая из них уже структур конфига означают несколько загрузок и вопрос «какая из них уже
провалидирована» на каждом использовании; связанные между собой поля провалидирована» на каждом использовании; связанные между собой поля
@@ -55,7 +61,7 @@ extends: arch/config.md
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт **ПОЧЕМУ.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
в код и обратно; при расхождении связь между полем файла и полем структуры в код и обратно; при расхождении связь между полем файла и полем структуры
восстанавливается чтением тегов, и проделывать это приходится для каждой восстанавливается чтением тегов, и проделывать это приходится для каждой
@@ -66,7 +72,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх. накладывается поверх.
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой **ПОЧЕМУ.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание, таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
видно ни целиком, ни из образца, и два потребителя одного поля со временем видно ни целиком, ни из образца, и два потребителя одного поля со временем
@@ -79,7 +85,7 @@ extends: arch/config.md
путь переопределяет флаг `--config=path`, образец рядом — путь переопределяет флаг `--config=path`, образец рядом —
`config.example.toml`. `config.example.toml`.
**Почему.** Фиксированное имя и переопределение из командной строки требует **ПОЧЕМУ.** Фиксированное имя и переопределение из командной строки требует
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
пишутся, не открывая код приложения. Соседство `config.toml` и пишутся, не открывая код приложения. Соседство `config.toml` и
@@ -98,7 +104,7 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
func (d Duration) Std() time.Duration { } func (d Duration) Std() time.Duration { }
``` ```
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое **ПОЧЕМУ.** `time.Duration` — это `int64`, и TOML разбирает его в голое
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
@@ -114,7 +120,7 @@ func (d Duration) Std() time.Duration { … }
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. **НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
**Почему.** Второй канал конфигурации — то, против чего написана базовая **ПОЧЕМУ.** Второй канал конфигурации — то, против чего написана базовая
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию. пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
@@ -130,7 +136,7 @@ func (d Duration) Std() time.Duration { … }
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$ ^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
``` ```
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, **ПОЧЕМУ.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
незаметно: правило числится механизированным, и глазами его больше никто не незаметно: правило числится механизированным, и глазами его больше никто не
проверяет. проверяет.
@@ -151,7 +157,7 @@ func (d Duration) Std() time.Duration { … }
| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | | GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | | GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не **ПОЧЕМУ.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
@@ -163,7 +169,7 @@ func (d Duration) Std() time.Duration { … }
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а **ПОЧЕМУ.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
@@ -176,7 +182,7 @@ func (d Duration) Std() time.Duration { … }
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`. ошибка, собранная `errors.Join`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
перезапусков по одному полю за раз, причём каждый следующий запуск перезапусков по одному полю за раз, причём каждый следующий запуск
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
@@ -186,7 +192,7 @@ func (d Duration) Std() time.Duration { … }
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно **ПОЧЕМУ.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
тогда, когда база зон его знает, и никакая проверка формата не отличит тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо доживает до первого форматирования времени — то есть до рантайма, мимо
@@ -197,7 +203,7 @@ fail-fast (GCFG-15).
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете. пакете.
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому **ПОЧЕМУ.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
@@ -209,23 +215,14 @@ zoneinfo, а сообщение указывает не на ту причину
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров. старта серверов и воркеров.
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не **ПОЧЕМУ.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
оставляет вызывающему возможности ни залогировать причину, ни дописать оставляет вызывающему возможности ни залогировать причину, ни дописать
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
во внешний сервис и записать в базу от имени процесса, который потом во внешний сервис и записать в базу от имени процесса, который потом
объявит, что не стартовал. объявит, что не стартовал.
<!-- local:поля -->
<!-- /local -->
<!-- local:механизировано -->
<!-- /local -->
## Связано ## Связано
- конвенция `time` — зона отображения и формат времени. - конвенция `time` — зона отображения и формат времени.
- конвенция `logging``slog`, которым падает невалидный конфиг. - конвенция `logging``slog`, которым падает невалидный конфиг.
<!-- local:связано -->
<!-- /local -->
+16 -17
View File
@@ -1,12 +1,17 @@
--- ---
topic: db-identifiers
prefix: GKEY prefix: GKEY
lang: go
extends: arch/db-identifiers.md extends: arch/db-identifiers.md
--- ---
# Идентификаторы: реализация на Go # Идентификаторы: реализация на Go
Как базовый слой выглядит в Go-приложении. Форма записи — Как базовый слой выглядит в Go-приложении.
`LANGUAGE.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Единая точка из `KEYS-3` — пакет `internal/ident`: он Единая точка из `KEYS-3` — пакет `internal/ident`: он
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
@@ -19,7 +24,7 @@ extends: arch/db-identifiers.md
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета **ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
`internal/ident`; других генераторов и парсеров id в коде нет. `internal/ident`; других генераторов и парсеров id в коде нет.
**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов **ПОЧЕМУ.** Реализация `KEYS-3` и `KEYS-4`. Вызов
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
выглядит нарушением: значение получается валидное, просто мимо нормализации выглядит нарушением: значение получается валидное, просто мимо нормализации
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
@@ -32,7 +37,7 @@ ULID-библиотеки — одна строка, доступная из л
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` **ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
внутри `Create`-метода слоя store. внутри `Create`-метода слоя store.
**Почему.** `KEYS-2` требует, чтобы значение было **ПОЧЕМУ.** `KEYS-2` требует, чтобы значение было
известно до вставки, но не говорит, кто его присваивает. Store — последний известно до вставки, но не говорит, кто его присваивает. Store — последний
слой, через который проходят все пути создания строки, включая импорт, слой, через который проходят все пути создания строки, включая импорт,
фоновые задания и тесты. Генерация выше по стеку делает присвоение фоновые задания и тесты. Генерация выше по стеку делает присвоение
@@ -45,7 +50,7 @@ ULID-библиотеки — одна строка, доступная из л
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся **ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
вызовом `ident.NewID()` там, где операция начинается. вызовом `ident.NewID()` там, где операция начинается.
**Почему.** Смысл такого идентификатора (`KEYS-7`) — **ПОЧЕМУ.** Смысл такого идентификатора (`KEYS-7`) —
сшивать записи лога всей операции. Созданный ниже по стеку или в момент сшивать записи лога всей операции. Созданный ниже по стеку или в момент
первой записи в базу, он не покрывает начальные шаги — а именно они нужны, первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
когда операция упала до того, как что-либо записала: без общего ключа эти когда операция упала до того, как что-либо записала: без общего ключа эти
@@ -56,7 +61,7 @@ ULID-библиотеки — одна строка, доступная из л
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в **ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
Go-миграции, порождаются с историческим временем строки, а не с текущим. Go-миграции, порождаются с историческим временем строки, а не с текущим.
**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не **ПОЧЕМУ.** Сортировка id тогда сохраняет историческую хронологию, а не
момент прогона миграции. Иначе все затронутые строки получают метку одного момент прогона миграции. Иначе все затронутые строки получают метку одного
момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id` момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id`
начинает врать ровно на том массиве данных, который старше всего. начинает врать ровно на том массиве данных, который старше всего.
@@ -68,7 +73,7 @@ Go-миграции, порождаются с историческим врем
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или **ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
callback'а бота — раньше, чем идентификатор попадёт в store. callback'а бота — раньше, чем идентификатор попадёт в store.
**Почему.** Реализация `KEYS-5`. Граница выбрана **ПОЧЕМУ.** Реализация `KEYS-5`. Граница выбрана
транспортная, потому что только на ней известен источник значения, от транспортная, потому что только на ней известен источник значения, от
которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
того, пришла она из URL или из собственной формы, и ответить по-разному того, пришла она из URL или из собственной формы, и ответить по-разному
@@ -79,7 +84,7 @@ callback'а бота — раньше, чем идентификатор поп
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип **СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
`string`. `string`.
**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им **ПОЧЕМУ.** Отдельный тип окупается только тогда, когда компилятор ловит им
ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и
`authorID`) он не спасает — оба будут одного типа, и различают их имена `authorID`) он не спасает — оба будут одного типа, и различают их имена
параметров. Зато он требует конверсий на каждой границе с sql-драйвером, параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
@@ -90,7 +95,7 @@ json и шаблонами, то есть даёт цену без выгоды.
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые **ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
можно перепутать, для них заводятся различимые типы. можно перепутать, для них заводятся различимые типы.
**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на **ПОЧЕМУ.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
типизацию навсегда. Условие названо ровно то, при котором тип начинает типизацию навсегда. Условие названо ровно то, при котором тип начинает
работать: пока все идентификаторы — `string`, подстановка одного вида работать: пока все идентификаторы — `string`, подстановка одного вида
вместо другого компилируется и обнаруживается только на данных. вместо другого компилируется и обнаруживается только на данных.
@@ -105,7 +110,7 @@ json и шаблонами, то есть даёт цену без выгоды.
| GKEY-8.1 | путь или query URL | 404 без обращения к store | | GKEY-8.1 | путь или query URL | 404 без обращения к store |
| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | | GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах **ПОЧЕМУ.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи — HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2 и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
значение сформировало само приложение, и невалидность означает баг значение сформировало само приложение, и невалидность означает баг
@@ -118,14 +123,8 @@ HTTP-кодов. В случае GKEY-8.1 снаружи это неотличи
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например **НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом. `ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
**Почему.** Инверсия правила «трансляция у источника» из конвенции **ПОЧЕМУ.** Инверсия правила «трансляция у источника» из конвенции
`errors`. Sentinel — сообщение от слоя, который знает факт: `errors`. Sentinel — сообщение от слоя, который знает факт:
строка не найдена, потому что store её искал. Сфабрикованный транспортом, строка не найдена, потому что store её искал. Сфабрикованный транспортом,
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
вообще поход в хранилище — а на этом держится вся диагностика по ошибкам. вообще поход в хранилище — а на этом держится вся диагностика по ошибкам.
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
+19 -22
View File
@@ -1,11 +1,17 @@
--- ---
topic: db-schema
prefix: MIGR prefix: MIGR
lang: go
--- ---
# Схема и миграции (SQLite, Go) # Схема и миграции (SQLite, Go)
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
Go-приложении. Форма записи — `LANGUAGE.md`. Go-приложении.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -21,7 +27,7 @@ Go-приложении. Форма записи — `LANGUAGE.md`.
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
goose. goose.
**Почему.** Журнал применённых версий goose держит в самой базе **ПОЧЕМУ.** Журнал применённых версий goose держит в самой базе
(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй (`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй
инструмент заводит второй журнал: миграция, применённая одним, для другого инструмент заводит второй журнал: миграция, применённая одним, для другого
выглядит неприменённой, и попытка накатить её повторно упирается в уже выглядит неприменённой, и попытка накатить её повторно упирается в уже
@@ -33,7 +39,7 @@ goose.
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
схемой. схемой.
**Почему.** Миграция и код, читающий схему, — одно изменение: колонка **ПОЧЕМУ.** Миграция и код, читающий схему, — одно изменение: колонка
появляется вместе с полем структуры и запросом. Лежащие в другом конце появляется вместе с полем структуры и запросом. Лежащие в другом конце
дерева миграции выпадают из поля зрения при правке store, и уезжает либо дерева миграции выпадают из поля зрения при правке store, и уезжает либо
код без миграции, либо миграция без кода; расходятся они на сервере, где код без миграции, либо миграция без кода; расходятся они на сервере, где
@@ -48,7 +54,7 @@ goose.
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | | MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | | MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, **ПОЧЕМУ.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
ошибиться, не добавляя ничего к результату. ошибиться, не добавляя ничего к результату.
@@ -64,7 +70,7 @@ goose.
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
ошибка исправляется новой миграцией вперёд. ошибка исправляется новой миграцией вперёд.
**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует **ПОЧЕМУ.** Down на сервере не возвращает прежнее состояние, а имитирует
его: колонка, которую убрал up, восстанавливается пустой, а строки, его: колонка, которую убрал up, восстанавливается пустой, а строки,
записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом
происходит молча — миграция отчитывается об успехе. Исправление, приехавшее происходит молча — миграция отчитывается об успехе. Исправление, приехавшее
@@ -80,7 +86,7 @@ goose.
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | | MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| MIGR-5.2 | необратимо преобразует данные | не пишется | | MIGR-5.2 | необратимо преобразует данные | не пишется |
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, **ПОЧЕМУ.** Down — инструмент разработки, где ветку переключают туда-сюда,
и именно там он обязан действительно обращать up. Имитация опаснее и именно там он обязан действительно обращать up. Имитация опаснее
отсутствия: разработчик применяет её, получает схему прежней формы и отсутствия: разработчик применяет её, получает схему прежней формы и
продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий
@@ -92,7 +98,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
изменением. изменением.
**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл. **ПОЧЕМУ.** Диаграмму читают вместо DDL — в этом весь её смысл.
Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить
это можно, только сверив её с миграциями, то есть проделав работу, которую это можно, только сверив её с миграциями, то есть проделав работу, которую
диаграмма экономит. Отложенное обновление не делается: изменение уже диаграмма экономит. Отложенное обновление не делается: изменение уже
@@ -108,7 +114,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
без `CHECK`-ограничения на список значений. без `CHECK`-ограничения на список значений.
**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной **ПОЧЕМУ.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))` версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))`
превращается из строки в коде в пересоздание таблицы по 12-шаговой превращается из строки в коде в пересоздание таблицы по 12-шаговой
процедуре, с копированием данных и восстановлением внешних ключей. процедуре, с копированием данных и восстановлением внешних ключей.
@@ -124,7 +130,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной. пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной.
**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает **ПОЧЕМУ.** Типа даты в SQLite нет, поэтому единственное, что делает
значения сравнимыми, — договорённость о формате; сам формат выбран не значения сравнимыми, — договорённость о формате; сам формат выбран не
здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется
лексикографически в том же порядке, что и лексикографически в том же порядке, что и
@@ -137,7 +143,7 @@ down останавливает сразу и заставляет пересо
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию **НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
на уровне схемы. на уровне схемы.
**Почему.** Время ставит приложение, и умолчание в схеме заводит второй **ПОЧЕМУ.** Время ставит приложение, и умолчание в схеме заводит второй
источник этого значения: пропущенное приложением поле не падает, а тихо источник этого значения: пропущенное приложением поле не падает, а тихо
получает время сервера базы — расхождение обнаруживается по данным, а не получает время сервера базы — расхождение обнаруживается по данным, а не
по ошибке. по ошибке.
@@ -150,7 +156,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя **ПОЧЕМУ.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
колонку удерживает только договорённость о представлении. Цена ошибки колонку удерживает только договорённость о представлении. Цена ошибки
здесь несимметрична: строка `'true'` в булевом контексте приводится к здесь несимметрична: строка `'true'` в булевом контексте приводится к
**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку **0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
@@ -162,7 +168,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
приложения. приложения.
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение, **ПОЧЕМУ.** Здесь конвенция схемы ничего не решает — она реализует решение,
принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там
ветвление или условие значило бы завести второй источник правды, и соседние ветвление или условие значило бы завести второй источник правды, и соседние
таблицы разъехались бы по разным ответам на один вопрос. таблицы разъехались бы по разным ответам на один вопрос.
@@ -175,7 +181,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после **ПОЧЕМУ.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
удаления последней строки номер переиспользуется. Протухшая ссылка на удаления последней строки номер переиспользуется. Протухшая ссылка на
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
наводится на другую сущность и возвращает правдоподобный, но чужой ответ. наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
@@ -185,18 +191,9 @@ down останавливает сразу и заставляет пересо
вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает: вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
там ключ строковый (MIGR-11). там ключ строковый (MIGR-11).
<!-- local:механизировано -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
## Связано ## Связано
- конвенция `time` — формат меток времени. - конвенция `time` — формат меток времени.
- конвенция `db-identifiers` — выбор первичных ключей. - конвенция `db-identifiers` — выбор первичных ключей.
- конвенция `errors` — граничные ошибки `database/sql` транслируются в - конвенция `errors` — граничные ошибки `database/sql` транслируются в
доменные у источника, в слое store. доменные у источника, в слое store.
<!-- local:связано -->
<!-- /local -->
+46 -43
View File
@@ -1,12 +1,18 @@
--- ---
topic: errors
prefix: GERR prefix: GERR
lang: go
--- ---
# Ошибки # Ошибки
Как ошибки строятся, оборачиваются и проверяются. Форма записи — Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
`LANGUAGE.md`. Где и когда ошибку **логировать** — в конвенции `logging` **логировать** — в конвенции `logging` (коротко: лог один раз на доменной
(коротко: лог один раз на доменной границе). границе).
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Две границы, о которых говорят правила ниже: Две границы, о которых говорят правила ниже:
@@ -26,7 +32,7 @@ prefix: GERR
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и **ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются. `fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. **ПОЧЕМУ.** Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки `slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
@@ -41,7 +47,7 @@ prefix: GERR
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте **НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
кодовой базы ради конкретной отладки. кодовой базы ради конкретной отладки.
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий **ПОЧЕМУ.** В коде появляются два способа устроить ошибку, и вызывающий
перестаёт знать, какой перед ним: обёртки склеиваются по-разному, перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая `errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный локально, перестаёт накапливаться — а накопление и есть единственный
@@ -52,7 +58,7 @@ prefix: GERR
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с **ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: `fmt.Errorf("parse magnet: %w", err)`. контекстом: `fmt.Errorf("parse magnet: %w", err)`.
**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько, **ПОЧЕМУ.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
стирает участок пути — по итоговому сообщению нельзя сказать, через какую стирает участок пути — по итоговому сообщению нельзя сказать, через какую
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
@@ -68,7 +74,7 @@ prefix: GERR
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | | GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| GERR-4.2 | причину сознательно не раскрываем | `%v` | | GERR-4.2 | причину сознательно не раскрываем | `%v` |
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка **ПОЧЕМУ.** Возражение против дефолтного `%w` — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями. становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
@@ -82,7 +88,7 @@ prefix: GERR
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю **НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
ошибку наружу. ошибку наружу.
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу **ПОЧЕМУ.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
детали утекут при любом глаголе. Подмена не решает задачу, ради которой детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
@@ -92,7 +98,7 @@ prefix: GERR
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». **СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка **ПОЧЕМУ.** Цепочка склеивается в одну строку через `": "`, и обёртка
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
на середине. Слова «failed» и «error» не несут информации: то, что перед на середине. Слова «failed» и «error» не несут информации: то, что перед
нами ошибка, известно из того, что это ошибка. Зато повторяются они на нами ошибка, известно из того, что это ошибка. Зато повторяются они на
@@ -102,7 +108,7 @@ prefix: GERR
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. **СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something **ПОЧЕМУ.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
failed» не сужает ничего и при этом занимает в сообщении место, которое мог failed» не сужает ничего и при этом занимает в сообщении место, которое мог
бы занять единственный полезный здесь факт — имя операции. бы занять единственный полезный здесь факт — имя операции.
@@ -111,7 +117,7 @@ failed» не сужает ничего и при этом занимает в
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: **НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. `"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то **ПОЧЕМУ.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
же событие названо дважды. Читателю приходится проверять, не два ли это же событие названо дважды. Читателю приходится проверять, не два ли это
разных места в коде, — то есть заикание не просто бесполезно, оно стоит разных места в коде, — то есть заикание не просто бесполезно, оно стоит
времени при каждом чтении лога. времени при каждом чтении лога.
@@ -128,7 +134,7 @@ failed» не сужает ничего и при этом занимает в
возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для
HTTP-клиентов, файловой системы, внешних SDK. HTTP-клиентов, файловой системы, внешних SDK.
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв **ПОЧЕМУ.** Иначе тип зависимости становится частью контракта всех слоёв
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql` выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
адаптер, а все ветвления в приложении — притом что снаружи адаптера адаптер, а все ветвления в приложении — притом что снаружи адаптера
@@ -144,7 +150,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | | GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | | GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от **ПОЧЕМУ.** Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных, структуры ошибки и переживает добавление полей. Тип заводится ради данных,
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
@@ -155,7 +161,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. **НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают **ПОЧЕМУ.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают
переписывать его свободно. Правка формулировки в нижнем слое молча ломает переписывать его свободно. Правка формулировки в нижнем слое молча ломает
ветвление наверху, и компилятор этого не видит. Это то же самое, что ветвление наверху, и компилятор этого не видит. Это то же самое, что
публичный API из строки лога. публичный API из строки лога.
@@ -171,7 +177,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно **ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
— конвенция `logging`. — конвенция `logging`.
**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и **ПОЧЕМУ.** Цепочка — единственный носитель диагностики (GERR-1), и
единственный канал, где её можно показать целиком, — тот, который видит единственный канал, где её можно показать целиком, — тот, который видит
владелец. Не записанная там, она не сохранится нигде: наружу идёт владелец. Не записанная там, она не сохранится нигде: наружу идёт
нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего. нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
@@ -181,7 +187,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не **ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
`err.Error()` и не детали реализации (`database/sql`, пути, стек). `err.Error()` и не детали реализации (`database/sql`, пути, стек).
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны **ПОЧЕМУ.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
— у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена — у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
причём раскрывают именно в момент, когда что-то пошло не так. причём раскрывают именно в момент, когда что-то пошло не так.
@@ -192,7 +198,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка». ошибка».
**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его **ПОЧЕМУ.** GERR-13 забирает у пользователя всю фактуру; без ключа его
обращение звучит как «у меня что-то не работает», и владелец ищет запись в обращение звучит как «у меня что-то не работает», и владелец ищет запись в
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
@@ -204,7 +210,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
задаётся один раз; транспорт без статусов (бот) берёт из него только задаётся один раз; транспорт без статусов (бот) берёт из него только
сообщение. сообщение.
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, **ПОЧЕМУ.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
важнее: единственная точка — это место, куда механически дописывается новая важнее: единственная точка — это место, куда механически дописывается новая
ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде» ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
@@ -215,21 +221,18 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и **ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
добавляется в маппинг (GERR-15) тем же изменением. добавляется в маппинг (GERR-15) тем же изменением.
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 **ПОЧЕМУ.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает «внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
шуметь в логе ровно там, где по нему ищут настоящие поломки. шуметь в логе ровно там, где по нему ищут настоящие поломки.
<!-- local:маппинг -->
<!-- /local -->
### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком ### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт **ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
признаком того, что маппинг её не знает. признаком того, что маппинг её не знает.
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли **ПОЧЕМУ.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
`ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус `ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в тоже не выбирается: известное пользовательское состояние лежало бы в
@@ -255,7 +258,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
Появился второй зритель или публичный доступ к экрану состояния — Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и на неё распространяется GERR-17.1. поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст **ПОЧЕМУ.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
@@ -268,7 +271,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ, **НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
ни в персистентную диагностику; источник вычищается на границе клиента. ни в персистентную диагностику; источник вычищается на границе клиента.
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в **ПОЧЕМУ.** Запрет абсолютен, потому что персистентная диагностика живёт в
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
самого секрета. Вычистка на границе клиента — единственное место, где ещё самого секрета. Вычистка на границе клиента — единственное место, где ещё
известно, какие поля запроса секретны: дальше ошибка едет как текст, и известно, какие поля запроса секретны: дальше ошибка едет как текст, и
@@ -279,7 +282,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое **ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
показывают пользователю. показывают пользователю.
**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей **ПОЧЕМУ.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
разные поля. Одно поле на оба назначения означает, что при первом же показе разные поля. Одно поле на оба назначения означает, что при первом же показе
записи наружу сырой текст уедет туда же — не по решению, а потому что поле записи наружу сырой текст уедет туда же — не по решению, а потому что поле
одно. одно.
@@ -291,7 +294,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и **ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
ошибка инициализации, из которой нельзя стартовать. ошибка инициализации, из которой нельзя стартовать.
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте **ПОЧЕМУ.** Паника не оставляет вызывающему выбора: обработать её на месте
нельзя, можно только уронить единицу обработки. Это верный ответ, когда нельзя, можно только уронить единицу обработки. Это верный ответ, когда
состояние процесса перестало описываться кодом: работа с нарушенным состояние процесса перестало описываться кодом: работа с нарушенным
инвариантом опаснее падения, а сервис, стартовавший без обязательной инвариантом опаснее падения, а сервис, стартовавший без обязательной
@@ -302,7 +305,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, **НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как `error`. плохой ввод, отсутствующая запись возвращаются как `error`.
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном **ПОЧЕМУ.** Сигнатура — единственное, что сообщает вызывающему о возможном
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
@@ -317,7 +320,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | | GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | | GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
**Почему.** `recover` работает только в той горутине, где случилась паника, **ПОЧЕМУ.** `recover` работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
неожиданным полем гасят весь сервис, включая части, к этой ошибке неожиданным полем гасят весь сервис, включая части, к этой ошибке
@@ -329,7 +332,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. **ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной **ПОЧЕМУ.** Это единственное место, где стек нужен (GERR-1): у восстановленной
паники цепочки `%w` нет вовсе. «index out of range» без стека не паники цепочки `%w` нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции, диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало. по нему нельзя сказать даже, в каком пакете упало.
@@ -343,10 +346,11 @@ HTTP-клиентов, файловой системы, внешних SDK.
| № | Где перехвачена паника | Что дальше | | № | Где перехвачена паника | Что дальше |
|---|---|---| |---|---|---|
| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются | | GERR-26.1 | обработчик HTTP-запроса, паника любая, кроме сигнала намеренного прерывания | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся | | GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
| GERR-26.3 | обработчик HTTP-запроса, паника — сигнал намеренного прерывания (`http.ErrAbortHandler`) | значение пробрасывается дальше, ответ не подменяется |
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о **ПОЧЕМУ.** Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, — баге в работе с данными этого элемента, а не о порче общего состояния, —
останавливать всё остальное не за что. Довод «let it crash» здесь работает останавливать всё остальное не за что. Довод «let it crash» здесь работает
не буквально: в OTP падает изолированный процесс под супервизором, а не узел не буквально: в OTP падает изолированный процесс под супервизором, а не узел
@@ -355,10 +359,11 @@ HTTP-клиентов, файловой системы, внешних SDK.
обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
процесс. процесс.
Продолжать, не исключив упавший элемент, нельзя: детерминированная паника Исключение упавшего элемента в GERR-26.2 — не осторожность, а условие
даёт бесконечный цикл — тот же элемент, тот же стек, залитый лог и нулевой прогресса: детерминированная паника даёт бесконечный цикл — тот же элемент,
прогресс. Это классический poison message, и лекарство берём то же, что тот же стек, залитый лог и нулевой прогресс. Это классический poison message,
принято в очередях: элемент выводится из оборота, а не берётся снова. У и лекарство здесь то же, что принято в очередях: элемент выводится из
оборота, а не берётся снова. У
цикла, который и так подтверждает прогресс — сдвигает офсет, помечает цикла, который и так подтверждает прогресс — сдвигает офсет, помечает
строку состоянием, — механизм для этого уже есть, заводить отдельный не строку состоянием, — механизм для этого уже есть, заводить отдельный не
нужно. нужно.
@@ -368,16 +373,17 @@ HTTP-клиентов, файловой системы, внешних SDK.
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
ответ целиком до записи там, где это возможно. ответ целиком до записи там, где это возможно.
Из GERR-26.1 есть одно исключение: `http.ErrAbortHandler`сигнал «прервать Отдельная строка GERR-26.3 нужна потому, что `http.ErrAbortHandler`не
обработку намеренно», и recover-обёртка пробрасывает его дальше, а не отказ, а сигнал «прервать обработку намеренно»: подмена его на 500 превратила
превращает в 500. Так поступают и стандартные обёртки вроде chi. бы штатный разрыв в ложную ошибку в логе и в метриках. Так поступают и
стандартные обёртки вроде chi.
### GERR-24. Независимые ошибки собираются `errors.Join` ### GERR-24. Независимые ошибки собираются `errors.Join`
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через `errors.Is`. разом; проверка собранного — по-прежнему через `errors.Is`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: `errors.Is` по такому результату не тот же список, но убивает ветвление: `errors.Is` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
@@ -387,6 +393,3 @@ HTTP-клиентов, файловой системы, внешних SDK.
- конвенция `logging` — где и когда ошибка попадает в лог. - конвенция `logging` — где и когда ошибка попадает в лог.
- `KEYS-7` — формат корреляционного ключа из `GERR-14`. - `KEYS-7` — формат корреляционного ключа из `GERR-14`.
<!-- local:механизировано -->
<!-- /local -->
+65 -67
View File
@@ -1,13 +1,18 @@
--- ---
topic: logging
prefix: SLOG prefix: SLOG
extends: arch/time.md lang: go
--- ---
# Логирование # Логирование
Как и когда писать логи. Это правила оформления кода (How), а не Как и когда писать логи. Это правила оформления кода (How), а не
спецификация поведения: наблюдаемые требования к логам, входящие в контракт спецификация поведения: наблюдаемые требования к логам, входящие в контракт
функциональности, живут в спеках. Форма записи — `LANGUAGE.md`. функциональности, живут в спеках.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Лог читают инструментами, а не глазами: повседневно — `jq` Лог читают инструментами, а не глазами: повседневно — `jq`
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) — (`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
@@ -25,7 +30,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в **ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
проде. проде.
**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена **ПОЧЕМУ.** Довод не в том, что текстовый вывод «расходит поля»: смена
хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым
dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и
поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное
@@ -36,7 +41,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа. **ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная **ПОЧЕМУ.** Фильтрация и агрегация работают по ключам; величина, вклеенная
в текст, достаётся только регуляркой, а регулярка ломается при первой же в текст, достаётся только регуляркой, а регулярка ломается при первой же
правке формулировки. Тип важен отдельно от ключа: число внутри строки не правке формулировки. Тип важен отдельно от ключа: число внутри строки не
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт. сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
@@ -46,7 +51,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey` **ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
(см. конвенцию `time`). (см. конвенцию `time`).
**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время **ПОЧЕМУ.** По умолчанию UTC не получится: встроенные хендлеры пишут время
в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного
процесса до и после смены TZ (или записи рядом с данными из БД) перестают процесса до и после смены TZ (или записи рядом с данными из БД) перестают
складываться в одну хронологию, причём сдвиг на целые часы глазом не виден складываться в одну хронологию, причём сдвиг на целые часы глазом не виден
@@ -64,7 +69,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Текст сообщения не собирается из переменных: **ДОЛЖЕН.** Текст сообщения не собирается из переменных:
`log.Info("download accepted", "download_id", id)`. `log.Info("download accepted", "download_id", id)`.
**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция **ПОЧЕМУ.** `msg` — то, по чему записи группируют и считают. Интерполяция
превращает одну категорию в множество уникальных строк, и вопрос «сколько превращает одну категорию в множество уникальных строк, и вопрос «сколько
раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы
одна категория не двоилась на варианты, различающиеся только заглавной одна категория не двоилась на варианты, различающиеся только заглавной
@@ -75,7 +80,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — **НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
отдельное поле. отдельное поле.
**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и **ПОЧЕМУ.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
фильтр по подсистеме становится сопоставлением с началом строки вместо фильтр по подсистеме становится сопоставлением с началом строки вместо
сравнения значения поля. Заодно это второй способ записать одно и то же: сравнения значения поля. Заодно это второй способ записать одно и то же:
категория дробится на варианты с префиксом и без, а совпадать они обязаны категория дробится на варианты с префиксом и без, а совпадать они обязаны
@@ -86,7 +91,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно **ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
состояние и по какой причине — данные, а не текст. состояние и по какой причине — данные, а не текст.
**Почему.** С отдельной категорией на каждый переход жизненный цикл **ПОЧЕМУ.** С отдельной категорией на каждый переход жизненный цикл
сущности собирается перечислением всех известных `msg` — и переход, сущности собирается перечислением всех известных `msg` — и переход,
добавленный в код позже, в это перечисление не попадёт: выборка тихо добавленный в код позже, в это перечисление не попадёт: выборка тихо
останется неполной. Единая категория даёт весь цикл одним фильтром и не останется неполной. Единая категория даёт весь цикл одним фильтром и не
@@ -97,7 +102,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет **НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
запись самого перехода. запись самого перехода.
**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых **ПОЧЕМУ.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
строки в логе; восстановление пропущенного перехода не стоит ничего, потому строки в логе; восстановление пропущенного перехода не стоит ничего, потому
что невозможно. что невозможно.
@@ -116,7 +121,7 @@ dev-выводом перестаёшь ежедневно гонять собс
| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» | | SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
| SLOG-8.4 | `ERROR` | владельцу, в разбор | | SLOG-8.4 | `ERROR` | владельцу, в разбор |
**Почему.** Адресат — единственный признак, по которому разные авторы в **ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
@@ -127,7 +132,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` **НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
везде одинаково серьёзен. везде одинаково серьёзен.
**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если **ПОЧЕМУ.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить
происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть
уровень перестаёт быть фильтром и становится подсказкой, требующей знания уровень перестаёт быть фильтром и становится подсказкой, требующей знания
@@ -137,7 +142,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. **ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится **ПОЧЕМУ.** `WARN` разбирают вручную и целиком. Как только в нём заводится
«ничего страшного», его перестают читать — и вместе с шумом теряется то «ничего страшного», его перестают читать — и вместе с шумом теряется то
единственное, ради чего уровень существует: предупреждение, на которое ещё единственное, ради чего уровень существует: предупреждение, на которое ещё
есть время отреагировать. есть время отреагировать.
@@ -151,7 +156,7 @@ dev-выводом перестаёшь ежедневно гонять собс
| SLOG-11.1 | по реальному действию или изменению | `INFO` | | SLOG-11.1 | по реальному действию или изменению | `INFO` |
| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | | SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность **ПОЧЕМУ.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
определяется долей записей, за которыми что-то стоит. Периодическая определяется долей записей, за которыми что-то стоит. Периодическая
операция даёт ровный поток при нулевой информации, в котором настоящие операция даёт ровный поток при нулевой информации, в котором настоящие
события тонут количественно: их не отфильтровать, потому что фильтровать события тонут количественно: их не отфильтровать, потому что фильтровать
@@ -159,14 +164,16 @@ dev-выводом перестаёшь ежедневно гонять собс
### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата ### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую **ДОЛЖЕН.** Фатальный сбой на старте пишется как `ERROR` и завершает процесс
степень даёт завершение процесса. ненулевым кодом.
**Почему.** Супервизор (docker, journald, systemd) отличает падение от **ПОЧЕМУ.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень
штатной остановки по коду возврата, а не по уровню последней записи. выражает не уровень записи, а сам факт завершения. Супервизор (docker,
Процесс, который написал `ERROR` и продолжил жить с неработающей journald, systemd) отличает падение от штатной остановки по коду возврата, а
конфигурацией, выглядит здоровым и будет получать трафик; изобретать же не по уровню последней записи. Процесс, который написал `ERROR` и продолжил
уровень выше `ERROR` не нужно — сам факт завершения информативнее. жить с неработающей конфигурацией, выглядит здоровым и будет получать трафик;
изобретать же уровень выше `ERROR` не нужно — сам факт завершения
информативнее.
## Поля: единый словарь ## Поля: единый словарь
@@ -174,7 +181,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. **ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той **ПОЧЕМУ.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той
же величины делает любую выборку по ней молча неполной: фильтр отработает, же величины делает любую выборку по ней молча неполной: фильтр отработает,
часть записей в него не попадёт, и заметить это можно, только заранее зная, часть записей в него не попадёт, и заметить это можно, только заранее зная,
что они должны были быть. что они должны были быть.
@@ -188,7 +195,7 @@ dev-выводом перестаёшь ежедневно гонять собс
| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | | SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | | SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые **ПОЧЕМУ.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
в любом проекте, от доменных, которые в каждом свои: по общему префиксу в любом проекте, от доменных, которые в каждом свои: по общему префиксу
запрос «все внешние вызовы» пишется без перечисления имён. Заимствование запрос «все внешние вызовы» пишется без перечисления имён. Заимствование
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
@@ -199,7 +206,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть **НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
имени, а не уровень вложенности. имени, а не уровень вложенности.
**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой **ПОЧЕМУ.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
записи независимо от её категории. Вложенность требует знать глубину записи независимо от её категории. Вложенность требует знать глубину
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
весь лог, распадаясь на запрос под каждую форму записи. весь лог, распадаясь на запрос под каждую форму записи.
@@ -215,7 +222,7 @@ dev-выводом перестаёшь ежедневно гонять собс
| SLOG-16.3 | запись об ошибке | `error` | | SLOG-16.3 | запись об ошибке | `error` |
| SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | | SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает **ПОЧЕМУ.** Набор задан не «на всякий случай»: без него запись не отвечает
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас `ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
@@ -227,16 +234,13 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не **НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`. заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке **ПОЧЕМУ.** Такое поле не несёт информации, но стоит места в каждой строке
и внимания при чтении. Критерий один на все поля словаря — им же решается, и внимания при чтении. Критерий один на все поля словаря — им же решается,
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
названо явно, поэтому правило отпадёт вместе со своей причиной: с названо явно, поэтому правило отпадёт вместе со своей причиной: с
появлением нескольких инстансов различающее поле (`service.version`) появлением нескольких инстансов различающее поле (`service.version`)
добавляется одной строкой при старте. добавляется одной строкой при старте.
<!-- local:словарь -->
<!-- /local -->
## Корреляция ## Корреляция
### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id` ### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id`
@@ -245,7 +249,7 @@ dev-выводом перестаёшь ежедневно гонять собс
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
конвенция `db-identifiers`, если взята.) конвенция `db-identifiers`, если взята.)
**Почему.** Идентификатор сущности уже существует, стабилен между **ПОЧЕМУ.** Идентификатор сущности уже существует, стабилен между
процессами и во времени — по нему собираются записи не одного прохода, а процессами и во времени — по нему собираются записи не одного прохода, а
всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое
только внутри одной операции, то есть дублирует ключ и добавляет второй только внутри одной операции, то есть дублирует ключ и добавляет второй
@@ -256,7 +260,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности. **ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
**Почему.** Принадлежность записи восстанавливается только в момент **ПОЧЕМУ.** Принадлежность записи восстанавливается только в момент
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем, Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
что идентификатор стоит везде, а не в удобных местах. что идентификатор стоит везде, а не в удобных местах.
@@ -276,7 +280,7 @@ log := log.With("download_id", id)
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
``` ```
**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в **ПОЧЕМУ.** Ручное дописывание ключа пропускают не в основном сценарии, а в
редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее
всего. Логгер из контекста дописывает ключ сам, и запись без всего. Логгер из контекста дописывает ключ сам, и запись без
идентификатора становится невозможной, а не маловероятной. идентификатора становится невозможной, а не маловероятной.
@@ -287,7 +291,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`. **ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и **ПОЧЕМУ.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
зависеть от того, кто писал конкретный вызов, и ради этого единообразия зависеть от того, кто писал конкретный вызов, и ради этого единообразия
@@ -298,7 +302,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только **НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
оборачивает (`%w`). оборачивает (`%w`).
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, **ПОЧЕМУ.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
и количество `ERROR` перестаёт соответствовать количеству отказов — а и количество `ERROR` перестаёт соответствовать количеству отказов — а
считают именно его. Контекст при этом не теряется: он накапливается в считают именно его. Контекст при этом не теряется: он накапливается в
цепочке обёрток и попадает в единственную запись на границе (SLOG-23). цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
@@ -307,21 +311,18 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. **ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и **ПОЧЕМУ.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
этим местом выбрана доменная граница, а не транспорт, потому что там этим местом выбрана доменная граница, а не транспорт, потому что там
известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
транспорты остаются тонкими. транспорты остаются тонкими.
<!-- local:границы -->
<!-- /local -->
### SLOG-24. Транспорт не логирует ошибку повторно ### SLOG-24. Транспорт не логирует ошибку повторно
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ **НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и на этом останавливается. (статус, сообщение пользователю) и на этом останавливается.
**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё **ПОЧЕМУ.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
только формулировкой и читается как второй сбой. Когда транспортов над только формулировкой и читается как второй сбой. Когда транспортов над
одним доменом несколько, дублирование ещё и множится, а расследование одним доменом несколько, дублирование ещё и множится, а расследование
начинается с вопроса, один это инцидент или два. начинается с вопроса, один это инцидент или два.
@@ -337,8 +338,9 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
| SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | | SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | | SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | | SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
| SLOG-25.4 | класса нет: отказ в классификацию не заведён | владельцу, как пропуск в классификации | `ERROR` с отметкой о непокрытом классе |
**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на **ПОЧЕМУ.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
экране — владельцу разбирать нечего; целостность первичных данных отделяет экране — владельцу разбирать нечего; целостность первичных данных отделяет
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный «надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
уровень для одного и того же отказа в зависимости от того, какой транспорт уровень для одного и того же отказа в зависимости от того, какой транспорт
@@ -349,9 +351,11 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
таблицу не входит: это не доменный отказ, и логирует его recover-граница таблицу не входит: это не доменный отказ, и логирует его recover-граница
вместе со стеком (конвенция `errors`). Искать его класс здесь не нужно. вместе со стеком (конвенция `errors`). Искать его класс здесь не нужно.
Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё Строка SLOG-25.4 говорит не о классе отказа, а о пропуске в самой
нет, потому что её просто забыли завести. Она логируется `ERROR` с классификации: ошибку забыли завести в маппинге. `ERROR` здесь — громкость,
признаком непокрытой (`GERR-25`). по которой пропуск находят фильтром, а не оценка тяжести отказа; саму отметку
о непокрытом классе ставит трансляция ошибки (`GERR-25` в конвенции
`errors`).
### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше ### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше
@@ -359,7 +363,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
она же в авто-обработке — `WARN`. она же в авто-обработке — `WARN`.
**Почему.** В ручном действии человек видит причину на экране и сам решает, **ПОЧЕМУ.** В ручном действии человек видит причину на экране и сам решает,
что делать дальше; запись нужна только для отладки. В автоматике не увидел что делать дальше; запись нужна только для отладки. В автоматике не увидел
никто, задача осталась недоведённой, и лог — единственное место, где это никто, задача осталась недоведённой, и лог — единственное место, где это
вообще проявится. вообще проявится.
@@ -369,7 +373,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: **ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
уровень задаёт наличие штатного повтора, а не текст ошибки. уровень задаёт наличие штатного повтора, а не текст ошибки.
**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и **ПОЧЕМУ.** Одиночный промах тика транзиентен — следующий тик повторит, и
вмешательство не требуется; `ERROR` на каждый такой промах обесценивает вмешательство не требуется; `ERROR` на каждый такой промах обесценивает
уровень, на который смотрят в первую очередь. Синхронная операция повтора уровень, на который смотрят в первую очередь. Синхронная операция повтора
не имеет: она провалилась целиком, результат никто не восстановит, и это не имеет: она провалилась целиком, результат никто не восстановит, и это
@@ -381,7 +385,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4. **ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость **ПОЧЕМУ.** Это единственный способ отличить «у нас баг» от «зависимость
легла»: на своей стороне видно лишь то, что операция не удалась. легла»: на своей стороне видно лишь то, что операция не удалась.
Выборочное логирование ломает и второе применение — доля неуспехов и Выборочное логирование ломает и второе применение — доля неуспехов и
распределение `duration_ms` считаются, только если знаменатель полный. распределение `duration_ms` считаются, только если знаменатель полный.
@@ -397,7 +401,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
| SLOG-29.3 | попытка не удалась, делается retry | `WARN` | | SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | | SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: **ПОЧЕМУ.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
уровень непригодным для главного вопроса «зависимость доступна?». уровень непригодным для главного вопроса «зависимость доступна?».
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
@@ -413,10 +417,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
уровень доменной записи об исходе тика. уровень доменной записи об исходе тика.
``` ```
WHEN зависимость недоступна и ретраи вызова исчерпаны КОГДА зависимость недоступна И ретраи вызова исчерпаны
ext-запись `ERROR` (SLOG-29.4) ТОГДА ext-запись `ERROR` (SLOG-29.4)
AND тик фонового цикла упал по той же причине И тик фонового цикла, упавший по той же причине,
доменная запись `WARN` (SLOG-27) даёт доменную запись `WARN` (SLOG-27)
``` ```
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR` Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
@@ -431,7 +435,7 @@ AND тик фонового цикла упал по той же причине
(`ext.status_code` записан); решение «это ошибка» принимает доменный (`ext.status_code` записан); решение «это ошибка» принимает доменный
вызывающий. вызывающий.
**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и **ПОЧЕМУ.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис
недоступен» с «сервис ответил нам нет» — это разные инциденты с разной недоступен» с «сервис ответил нам нет» — это разные инциденты с разной
реакцией, и различает их как раз `ext`-уровень. Что 404 значит для реакцией, и различает их как раз `ext`-уровень. Что 404 значит для
@@ -444,7 +448,7 @@ AND тик фонового цикла упал по той же причине
**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа. **ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и **ПОЧЕМУ.** Это аудит обращений, а не отладка: запись отвечает на «кто и
когда приходил», и ценность у неё одинаковая при любом коде ответа. когда приходил», и ценность у неё одинаковая при любом коде ответа.
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах, Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
@@ -454,7 +458,7 @@ AND тик фонового цикла упал по той же причине
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности. **ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id` **ПОЧЕМУ.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
сущности нет — связать его записи между собой больше нечем. сущности нет — связать его записи между собой больше нечем.
@@ -463,7 +467,7 @@ AND тик фонового цикла упал по той же причине
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. **ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают **ПОЧЕМУ.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
@@ -477,7 +481,7 @@ AND тик фонового цикла упал по той же причине
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
в ссылках. в ссылках.
**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код, **ПОЧЕМУ.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован
с момента записи, а не с момента, когда это заметили, и вычистить его задним с момента записи, а не с момента, когда это заметили, и вычистить его задним
числом из уже собранных копий нельзя. числом из уже собранных копий нельзя.
@@ -487,7 +491,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — **ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
`DEBUG`, с вычисткой секретов и обрезкой по длине. `DEBUG`, с вычисткой секретов и обрезкой по длине.
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав **ПОЧЕМУ.** Содержимое пришло снаружи: размер не ограничен, состав
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией; выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
обрезка не даёт одной записи вытеснить весь остальной лог за период. обрезка не даёт одной записи вытеснить весь остальной лог за период.
@@ -496,7 +500,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. **СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
**Почему.** Для отладки почти всегда достаточно ответа «значение было или **ПОЧЕМУ.** Для отладки почти всегда достаточно ответа «значение было или
не было» — потеря полезности близка к нулю, а риск снимается целиком. не было» — потеря полезности близка к нулю, а риск снимается целиком.
Правило нужно потому, что решение принимается в момент написания строки, Правило нужно потому, что решение принимается в момент написания строки,
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
@@ -507,7 +511,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до **ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
обёртки — раньше трансляции в доменную (конвенция `errors`). обёртки — раньше трансляции в доменную (конвенция `errors`).
**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт **ПОЧЕМУ.** `*url.Error` встраивает полный URL запроса, а секрет живёт
прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль
из userinfo, остального не трогает, поэтому ошибка уносит секрет и в из userinfo, остального не трогает, поэтому ошибка уносит секрет и в
обёртку, и в лог целиком. Порядок — часть нормы: санитизация после обёртку, и в лог целиком. Порядок — часть нормы: санитизация после
@@ -521,14 +525,11 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого **НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
способа нет. способа нет.
**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и **ПОЧЕМУ.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
в любую запись, куда URL попал целиком, — то есть обязывает помнить про в любую запись, куда URL попал целиком, — то есть обязывает помнить про
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
<!-- local:секреты -->
<!-- /local -->
## Куда пишем ## Куда пишем
### SLOG-39. Логи идут в `stdout` одним потоком ### SLOG-39. Логи идут в `stdout` одним потоком
@@ -536,7 +537,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам **ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
не маршрутизируем. не маршрутизируем.
**Почему.** Приложение, которое само решает, что куда писать, дублирует **ПОЧЕМУ.** Приложение, которое само решает, что куда писать, дублирует
работу супервизора и расходится с ней при первой же смене окружения: срок работу супервизора и расходится с ней при первой же смене окружения: срок
хранения, сжатие и ротация оказываются настроены в двух местах и по-разному. хранения, сжатие и ротация оказываются настроены в двух местах и по-разному.
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
@@ -546,7 +547,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом. **ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
**Почему.** Уровень — единственный регулятор объёма, доступный без **ПОЧЕМУ.** Уровень — единственный регулятор объёма, доступный без
пересборки; если `DEBUG` в проде включается только правкой кода, его не пересборки; если `DEBUG` в проде включается только правкой кода, его не
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2). что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
@@ -559,6 +560,3 @@ API-ключи и токены, `Authorization`-заголовки, аутент
санитизации (SLOG-37). санитизации (SLOG-37).
- конвенция `db-identifiers` — откуда берутся стабильные идентификаторы, - конвенция `db-identifiers` — откуда берутся стабильные идентификаторы,
на которых держится корреляция (SLOG-18). на которых держится корреляция (SLOG-18).
<!-- local:механизировано -->
<!-- /local -->
+23 -21
View File
@@ -1,13 +1,18 @@
--- ---
topic: time
prefix: GTIM prefix: GTIM
lang: go
extends: arch/time.md extends: arch/time.md
--- ---
# Время: реализация на Go # Время: реализация на Go
Как требования базового слоя выполняются в Go-коде: откуда берётся Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас»,
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами. в каком виде время попадает в базу и в логи, что делать с зонами.
Форма записи — `LANGUAGE.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Правила ## Правила
@@ -16,7 +21,7 @@ extends: arch/time.md
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.Now()` по коду. `time.Now().UTC()`, а не из `time.Now()` по коду.
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна **ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат: ни одна
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
когда-то локальной, и восстановить смещение задним числом не по чему. когда-то локальной, и восстановить смещение задним числом не по чему.
@@ -30,7 +35,7 @@ extends: arch/time.md
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно. получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в **ПОЧЕМУ.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
взаимная обратимость записи и чтения держатся ровно до первого второго взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений, layout — а расхождение проявится не на записи, а при сравнении значений,
@@ -46,7 +51,7 @@ layout — а расхождение проявится не на записи,
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | | GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) | | GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
**Почему.** GTIM-1 без механической проверки держится на внимании, а **ПОЧЕМУ.** GTIM-1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по Исключения перечисляются исчерпывающе, потому что каждое из них — само по
@@ -60,7 +65,7 @@ layout — а расхождение проявится не на записи,
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в `//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
конфигурации линтера для него не заводятся. конфигурации линтера для него не заводятся.
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует **ПОЧЕМУ.** Запись в конфиге — второй реестр тех же двух мест: она адресует
исключение путём к файлу, отвязывается при переносе кода и продолжает исключение путём к файлу, отвязывается при переносе кода и продолжает
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список — переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
@@ -74,7 +79,7 @@ layout — а расхождение проявится не на записи,
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому **ПОЧЕМУ.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
лексикографический порядок совпадает с хронологическим только при лексикографический порядок совпадает с хронологическим только при
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше** одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
@@ -88,7 +93,7 @@ layout — а расхождение проявится не на записи,
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит **ПОЧЕМУ.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором от значения: соседние записи получают разную ширину, и свойство, на котором
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок. проходит — отказывает только порядок.
@@ -99,7 +104,7 @@ layout — а расхождение проявится не на записи,
к каноническому виду явно, а не считается каноническим по факту успешного к каноническому виду явно, а не считается каноническим по факту успешного
разбора. разбора.
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и **ПОЧЕМУ.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но **писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
@@ -111,7 +116,7 @@ Go-механика, из-за которой его легко нарушить
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование **ПОЧЕМУ.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией. с конвенцией.
@@ -129,7 +134,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
} }
``` ```
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут **ПОЧЕМУ.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое, ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
неверная зона выглядит как совершенно валидное время, а записи из разных неверная зона выглядит как совершенно валидное время, а записи из разных
@@ -140,7 +145,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности GTIM-4. приводится к секундной точности GTIM-4.
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование **ПОЧЕМУ.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе — одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
нарушено. Общее у лога и базы одно — зона (GTIM-8). нарушено. Общее у лога и базы одно — зона (GTIM-8).
@@ -150,7 +155,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`. локальным `//nolint`.
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это **ПОЧЕМУ.** `store.Now()` приводит время к UTC через `.UTC()`, а это
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
меткам, зависит от подводки часов: перевод назад даёт отрицательную меткам, зависит от подводки часов: перевод назад даёт отрицательную
длительность, скачок вперёд — выброс в измерениях, и оба случая длительность, скачок вперёд — выброс в измерениях, и оба случая
@@ -161,7 +166,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОЛЖЕН.** База зон вшивается в бинарь. **ДОЛЖЕН.** База зон вшивается в бинарь.
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых **ПОЧЕМУ.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
`main` держит это решение в одном видимом месте, а не в случайном пакете, `main` держит это решение в одном видимом месте, а не в случайном пакете,
@@ -172,16 +177,13 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях. представления, но не в хранимых значениях и не в вычислениях.
**Почему.** Зона отображения — настройка, и её меняют. Протекая в **ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено. суток у того, что давно посчитано и сохранено.
Календарные вычисления бизнес-логики берут зону явно — как описано в Явную зону в календарных вычислениях требует TIME-12 — это его правило, а не
базовом слое. второе такое же здесь.
<!-- local:механизировано -->
<!-- /local -->
## Связано ## Связано
+16 -22
View File
@@ -1,12 +1,17 @@
--- ---
topic: app-directories
prefix: ANSD prefix: ANSD
stack: ansible
extends: arch/app-directories.md extends: arch/app-directories.md
--- ---
# Категории директорий: реализация в Ansible # Категории директорий: реализация в Ansible
Как категории из базового слоя раскладываются на сервере Как категории из базового слоя раскладываются на сервере плейбуком.
плейбуком. Форма записи — `LANGUAGE.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -24,7 +29,7 @@ extends: arch/app-directories.md
состоит из нескольких директорий, имя даётся по содержимому (`media_dir`, состоит из нескольких директорий, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`). `uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача **ПОЧЕМУ.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении. обнаружится это при восстановлении.
@@ -33,7 +38,7 @@ extends: arch/app-directories.md
**СЛЕДУЕТ.** Список директорий в единственной задаче создания. **СЛЕДУЕТ.** Список директорий в единственной задаче создания.
**Почему.** Этот список — единственное место, где декларировано всё, что **ПОЧЕМУ.** Этот список — единственное место, где декларировано всё, что
приложение пишет на диск. Разнесённое по нескольким задачам создание приложение пишет на диск. Разнесённое по нескольким задачам создание
отвечает на вопрос «какие директории есть у приложения» только чтением отвечает на вопрос «какие директории есть у приложения» только чтением
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
@@ -45,7 +50,7 @@ extends: arch/app-directories.md
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на (`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
репозиторий и фиксируется ниже. репозиторий и фиксируется ниже.
**Почему.** Правило про соответствие владельца рантайму, а не про **ПОЧЕМУ.** Правило про соответствие владельца рантайму, а не про
конкретную модель: приложение в контейнере пишет от определённого uid, и конкретную модель: приложение в контейнере пишет от определённого uid, и
если директория принадлежит другому, отказ произойдёт не при деплое, а при если директория принадлежит другому, отказ произойдёт не при деплое, а при
первой записи — то есть после того, как плейбук отчитался об успехе. Выбор первой записи — то есть после того, как плейбук отчитался об успехе. Выбор
@@ -53,15 +58,12 @@ extends: arch/app-directories.md
изоляцией по приложениям решают разные задачи, и навязывать одну модель изоляцией по приложениям решают разные задачи, и навязывать одну модель
обоим значит гарантировать вечное отступление. обоим значит гарантировать вечное отступление.
<!-- local:модель-владельца -->
<!-- /local -->
### ANSD-4. Список бэкапа собирается из тех же переменных ### ANSD-4. Список бэкапа собирается из тех же переменных
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки **ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути. которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его **ПОЧЕМУ.** Правило вывода списка механическое (ANSD-5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в обоих местах. Независимо набранный список расходится тихо и проявляется в
@@ -72,7 +74,7 @@ extends: arch/app-directories.md
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в **ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет. списке; конфигурация и кеш — нет.
**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без **ПОЧЕМУ.** Реализация правила базовой конвенции. Кеш раздувает снапшот без
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот. облако, и источником истины для секретов остаётся vault, а не снапшот.
@@ -80,7 +82,7 @@ extends: arch/app-directories.md
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. **СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает **ПОЧЕМУ.** Плейбук — источник истины для конфигурации, и `:ro` превращает
это из договорённости в свойство системы: приложение, которое втихую это из договорённости в свойство системы: приложение, которое втихую
переписывает свой конфиг, падает сразу, а не расходится с репозиторием переписывает свой конфиг, падает сразу, а не расходится с репозиторием
незаметно. Приложение, которому запись в конфиг нужна по устройству, незаметно. Приложение, которому запись в конфиг нужна по устройству,
@@ -90,7 +92,7 @@ extends: arch/app-directories.md
**ДОЛЖЕН.** Файл не переносится во вложенную директорию. **ДОЛЖЕН.** Файл не переносится во вложенную директорию.
**Почему.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило **ПОЧЕМУ.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило
внешнее по происхождению, но нарушается легко — при попытке «навести внешнее по происхождению, но нарушается легко — при попытке «навести
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место. место.
@@ -100,7 +102,7 @@ extends: arch/app-directories.md
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, **СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения. принадлежащий пользователю приложения.
**Почему.** Файл под `0600` не наследуется дочерними процессами, не виден в **ПОЧЕМУ.** Файл под `0600` не наследуется дочерними процессами, не виден в
`docker inspect` и не оседает в compose-файле на диске. Это те же три `docker inspect` и не оседает в compose-файле на диске. Это те же три
довода, по которым базовая конвенция конфигурации выбирает файл вместо довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения. окружения.
@@ -109,15 +111,7 @@ extends: arch/app-directories.md
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. **ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на **ПОЧЕМУ.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на ANSD-8 при ближайшем касании. секреты из файла, переводится на ANSD-8 при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->
## Связано
<!-- local:связано -->
<!-- /local -->
+44 -45
View File
@@ -1,13 +1,18 @@
--- ---
topic: web-ui
prefix: HTMX prefix: HTMX
stack: htmx
--- ---
# Веб-UI на htmx # Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений,
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI обработчики действий, деградация без JS, ошибки. Что именно UI показывает и
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи какие действия поддерживает — в спеках, не здесь.
`LANGUAGE.md`.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке
конвенций версии 1 — тогда и только тогда, когда написаны заглавными.
Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors` `DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors`
@@ -27,7 +32,7 @@ prefix: HTMX
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка. без Node и бандлера, без реактивного фреймворка.
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и **ПОЧЕМУ.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
@@ -41,7 +46,7 @@ prefix: HTMX
(копирование в буфер обмена и подобное); доменное состояние считает сервер, (копирование в буфер обмена и подобное); доменное состояние считает сервер,
клиент свопит присланную разметку. клиент свопит присланную разметку.
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую **ПОЧЕМУ.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
@@ -53,7 +58,7 @@ prefix: HTMX
только когда есть виджет, которому он действительно нужен, и отдельным только когда есть виджет, которому он действительно нужен, и отдельным
решением. решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего **ПОЧЕМУ.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2 списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент, перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на когда цену видно целиком: она не в килобайтах, а в том, что дальше на
@@ -67,7 +72,7 @@ prefix: HTMX
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
обработчика; отдельной разметки под фрагмент нет. обработчика; отдельной разметки под фрагмент нет.
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту, **ПОЧЕМУ.** Две копии одной разметки расходятся молча: правку вносят в ту,
что открыта, и страница начинает выглядеть иначе, чем результат свопа того что открыта, и страница начинает выглядеть иначе, чем результат свопа того
же региона. Заметно это становится только на глаз и только тому, кто открыл же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд. оба пути подряд.
@@ -77,7 +82,7 @@ prefix: HTMX
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`. регион, и ответный фрагмент несёт тот же `id`.
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с **ПОЧЕМУ.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
@@ -88,7 +93,7 @@ prefix: HTMX
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка. htmx-ветка.
**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не **ПОЧЕМУ.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который HTMX-4 закрывает для разметки. класс расхождений, который HTMX-4 закрывает для разметки.
@@ -121,7 +126,7 @@ if actionErr != nil {
s.render(w, "source_block", view) // фрагмент = тот же шаблон s.render(w, "source_block", view) // фрагмент = тот же шаблон
``` ```
**Почему.** Ветвление до вызова даёт две реализации одного действия, и **ПОЧЕМУ.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот. деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
@@ -133,7 +138,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ. буфер пишется в ответ.
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть **ПОЧЕМУ.** Прямая запись в ответ отправляет клиенту статус и часть
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
региона», и причина по такому симптому не читается. региона», и причина по такому симптому не читается.
@@ -146,7 +151,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5). партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают **ПОЧЕМУ.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие. раунд-трип на каждое действие.
@@ -156,7 +161,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие. регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого **ПОЧЕМУ.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление. связи нет; гонка же тем менее наблюдаема, чем реже обновление.
@@ -169,7 +174,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
рабочий обработчик. рабочий обработчик.
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик, **ПОЧЕМУ.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
ничего, молча. Тот же `action` — единственное, что делает действие ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS. проверяемым без браузера с JS.
@@ -179,7 +184,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; **ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет. клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский **ПОЧЕМУ.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе. перезагрузку, его можно послать ссылкой и увидеть в логе.
@@ -193,7 +198,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) | | HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | | HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на **ПОЧЕМУ.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
виджета, который был нужен. Запись в отступления держит список честным: виджета, который был нужен. Запись в отступления держит список честным:
видно, какие именно места ломаются с выключенным JS, а не «где-то видно, какие именно места ломаются с выключенным JS, а не «где-то
@@ -206,7 +211,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с **ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть **ПОЧЕМУ.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Своп ошибочных ответов настраивается пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая (`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2. такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
@@ -225,7 +230,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`, ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается. `response-targets`) не настраивается.
**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл. **ПОЧЕМУ.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя, свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель и пользователь повторяет действие, которое могло уже примениться. Слушатель
@@ -241,7 +246,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала; **ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала;
`err.Error()` в разметку не рендерится. `err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём **ПОЧЕМУ.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница: легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14) разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
@@ -251,7 +256,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под **ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются. сообщение не переиспользуются.
**Почему.** У доменного поля может быть своё непустое значение, и сообщение **ПОЧЕМУ.** У доменного поля может быть своё непустое значение, и сообщение
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
@@ -262,7 +267,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает **НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение. прежний выбор плюс сообщение.
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное, **ПОЧЕМУ.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
что пользователь узнает о состоянии. Показав намеренное состояние вместо что пользователь узнает о состоянии. Показав намеренное состояние вместо
фактического, интерфейс расходится с сервером, и следующее действие человек фактического, интерфейс расходится с сервером, и следующее действие человек
делает по ложной картине — на сервере оно применится к другому объекту. делает по ложной картине — на сервере оно применится к другому объекту.
@@ -285,7 +290,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов. `hx-*`-атрибутов.
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка **ПОЧЕМУ.** Иначе опрос не прекращается никогда: каждая открытая вкладка
держит постоянный поток запросов за неизменными данными, и закрывает его держит постоянный поток запросов за неизменными данными, и закрывает его
только пользователь. Условие остановки живёт в разметке ответа, потому что только пользователь. Условие остановки живёт в разметке ответа, потому что
это единственный канал, которым сервер управляет поллером. это единственный канал, которым сервер управляет поллером.
@@ -299,7 +304,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет **ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса. приложение, а не по ответу внешнего сервиса.
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его **ПОЧЕМУ.** Внешний сервис отвечает не всегда и не одинаково: на его
недоступности поллер либо останавливается, пока работа идёт, либо не недоступности поллер либо останавливается, пока работа идёт, либо не
останавливается никогда. Приложение — единственный участник, который знает останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике. про операцию всё и может ответить на каждом тике.
@@ -309,7 +314,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его **ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое. содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и **ПОЧЕМУ.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером, выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
@@ -320,7 +325,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где **НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего. редактировать нечего.
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст **ПОЧЕМУ.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
внутри него. У поллера это происходит по таймеру, то есть в момент, который внутри него. У поллера это происходит по таймеру, то есть в момент, который
пользователь не выбирал: текст исчезает посреди набора и воспроизводится пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод». как «приложение стирает мой ввод».
@@ -329,7 +334,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. **НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные **ПОЧЕМУ.** Прямой запрос из браузера выносит наружу адрес и учётные данные
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением. серверным изменением.
@@ -343,7 +348,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | | HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | | HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на **ПОЧЕМУ.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
его недоступность становится недоступностью страницы. Снимок разрывает эту его недоступность становится недоступностью страницы. Снимок разрывает эту
связь: частоту обращений к внешнему сервису задаёт воркер, а не связь: частоту обращений к внешнему сервису задаёт воркер, а не
@@ -362,7 +367,7 @@ hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML" hx-select="#item-main" hx-swap="outerHTML"
``` ```
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует **ПОЧЕМУ.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (HTMX-4). копии разметки (HTMX-4).
@@ -376,7 +381,7 @@ view, — и дальше два обработчика расходятся п
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и **ПОЧЕМУ.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого страницы. Не сохраняется при свопе только контекст внутри самого
@@ -387,7 +392,7 @@ view, — и дальше два обработчика расходятся п
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией. обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
**Почему.** Своп для такого действия оставил бы на месте регион, **ПОЧЕМУ.** Своп для такого действия оставил бы на месте регион,
описывающий объект, которого на странице больше нет. Отсутствие описывающий объект, которого на странице больше нет. Отсутствие
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
@@ -398,7 +403,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18). промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и **ПОЧЕМУ.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу — всё это время пользователь принимает решения по несуществующему исходу —
включая повтор действия, которое на самом деле выполняется. Промежуточное включая повтор действия, которое на самом деле выполняется. Промежуточное
состояние вдобавок объясняет, почему регион продолжает обновляться сам. состояние вдобавок объясняет, почему регион продолжает обновляться сам.
@@ -411,7 +416,7 @@ htmx-атрибутов при этом само работает маркеро
фрагментом, поверхность передаётся явным скрытым полем фрагментом, поверхность передаётся явным скрытым полем
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`. (`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` **ПОЧЕМУ.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
может не прийти вовсе; и то и другое меняется без участия обработчика, и может не прийти вовсе; и то и другое меняется без участия обработчика, и
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
действием, поэтому связь «эта страница → этот фрагмент» читается там, где действием, поэтому связь «эта страница → этот фрагмент» читается там, где
@@ -423,7 +428,7 @@ htmx-атрибутов при этом само работает маркеро
400, когда поля `surface` в запросе нет; поверхность по умолчанию не 400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается. выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие — **ПОЧЕМУ.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
@@ -443,7 +448,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с **ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`. `Cache-Control: public, max-age=31536000, immutable`.
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго **ПОЧЕМУ.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
@@ -454,7 +459,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL **ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона. строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно **ПОЧЕМУ.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) — от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
@@ -465,7 +470,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без **ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии. параметра версии.
**Почему.** Содержимое под этим именем не меняется: обновление вендора **ПОЧЕМУ.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это HTMX-30. явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
@@ -476,7 +481,7 @@ htmx-атрибутов при этом само работает маркеро
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой (`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
задачи. задачи.
**Почему.** Манифест делает версию и происхождение ассета видимыми в **ПОЧЕМУ.** Манифест делает версию и происхождение ассета видимыми в
diff'е — у закоммиченного минифицированного файла обновление выглядит diff'е — у закоммиченного минифицированного файла обновление выглядит
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 — стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
единственная проверка, что скачали то же самое, что проверяли; зависимость единственная проверка, что скачали то же самое, что проверяли; зависимость
@@ -486,13 +491,7 @@ diff'е — у закоммиченного минифицированного
**ДОЛЖЕН.** Внешних хостов во время выполнения нет. **ДОЛЖЕН.** Внешних хостов во время выполнения нет.
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и **ПОЧЕМУ.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
вдобавок разворачивается в сети без выхода наружу, где CDN просто не вдобавок разворачивается в сети без выхода наружу, где CDN просто не
отвечает. отвечает.
<!-- local:эталоны -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->
-47
View File
@@ -1,47 +0,0 @@
# Реестр префиксов правил.
#
# Префикс — четыре заглавные латинские буквы, уникальные по всему канону.
# Он выбирается под файл, а не выводится по формуле: префикс нужен, чтобы
# по нему искать, а не чтобы его разбирать. Поэтому подходящее слово лучше
# закономерности.
#
# Правила реестра:
#
# - префикс не переименовывается и не переиспользуется никогда — ссылка
# из чужого репозитория обязана продолжать указывать на то же место;
# - при удалении или разделении файла префикс уходит в [retired], а не
# освобождается;
# - переезд файла между осями префикс не меняет: идентификатор правила
# не зависит от таксономии;
# - вынос части правил в новый файл — это новый префикс и новая
# нумерация: перенос правила между документами есть смысловое
# изменение, а не переименование;
# - тот же префикс продублирован в шапке файла (`prefix:`), conv сверяет.
#
# Пути даются от корня репозитория, а не от `conventions/`: реестр покрывает
# и обвязку тоже.
#
# Локальные правила репозиториев берут свои префиксы и объявляют их в
# `.conventions.toml` копии. Они обязаны не пересекаться с этим реестром.
[live]
DIRS = "conventions/arch/app-directories.md"
CONF = "conventions/arch/config.md"
KEYS = "conventions/arch/db-identifiers.md"
TIME = "conventions/arch/time.md"
GCFG = "conventions/lang/go/config.md"
GKEY = "conventions/lang/go/db-identifiers.md"
MIGR = "conventions/lang/go/db-schema.md"
GERR = "conventions/lang/go/errors.md"
SLOG = "conventions/lang/go/logging.md"
GTIM = "conventions/lang/go/time.md"
ANSD = "conventions/stack/ansible/app-directories.md"
HTMX = "conventions/stack/htmx/web-ui.md"
# Обвязка канона: не синхронизируется в репозитории, но правила
# записаны тем же языком и цитируются по номерам, поэтому префикс нужен.
META = "GUIDE.md"
[retired]
# Пусто. Сюда попадают префиксы удалённых и разделённых файлов вместе с
# причиной и датой, чтобы их нельзя было выдать повторно.