Compare commits
10
Commits
c96566d4b4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271603122d
|
||
|
|
34d53d667d
|
||
|
|
0d335ff58d
|
||
|
|
9a3a89358f
|
||
|
|
7fb60828db
|
||
|
|
9c86d9f2de
|
||
|
|
787d0bb5ea
|
||
|
|
11fc9e1fee
|
||
|
|
b516bfb02c
|
||
|
|
e8fdc98557
|
@@ -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"
|
||||||
@@ -9,7 +9,7 @@ code in this repository.
|
|||||||
|
|
||||||
Канон конвенций разработки для личных проектов. Сами конвенции лежат в
|
Канон конвенций разработки для личных проектов. Сами конвенции лежат в
|
||||||
`conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`,
|
`conventions/{arch,lang/<язык>,stack/<стек>}/`; обвязка канона (`README.md`,
|
||||||
`LANGUAGE.md`, `GUIDE.md`, `READING.md`, `manifest.toml`, `conv`) живёт в
|
`LANGUAGE.md`, `GUIDE.md`, `READING.md`, `.conventions-suite.toml`) живёт в
|
||||||
корне. К потребителю из неё едет только `READING.md` — короткое описание языка
|
корне. К потребителю из неё едет только `READING.md` — короткое описание языка
|
||||||
для читателя копий.
|
для читателя копий.
|
||||||
|
|
||||||
@@ -76,9 +76,9 @@ code in this repository.
|
|||||||
латиницей, рекомендуется нижний kebab-case, годится любой идентификатор,
|
латиницей, рекомендуется нижний kebab-case, годится любой идентификатор,
|
||||||
пригодный для имени файла.
|
пригодный для имени файла.
|
||||||
- META-28: тема объявлена в шапке (`topic: time`) и стоит в манифесте набора
|
- META-28: тема объявлена в шапке (`topic: time`) и стоит в манифесте набора
|
||||||
(`manifest.toml`, секция `[topics.live]`). Слои одной темы несут одно имя —
|
(`.conventions-suite.toml`, секция `[topics.live]`). Слои одной темы несут
|
||||||
по нему собираются в один файл, как бы ни назывались их файлы; имя файла
|
одно имя — по нему собираются в один файл, как бы ни назывались их файлы;
|
||||||
повторяет тему из удобства.
|
имя файла повторяет тему из удобства.
|
||||||
- META-29: имя темы не переиспользуется, снятое уходит в `[topics.retired]`
|
- META-29: имя темы не переиспользуется, снятое уходит в `[topics.retired]`
|
||||||
с причиной и датой. Оно живёт в `origin:` копий и в подписках манифестов.
|
с причиной и датой. Оно живёт в `origin:` копий и в подписках манифестов.
|
||||||
- Формат `<ПРЕФИКС>-<номер>`, нумерация сквозная внутри файла. Порядок правил
|
- Формат `<ПРЕФИКС>-<номер>`, нумерация сквозная внутри файла. Порядок правил
|
||||||
@@ -135,13 +135,47 @@ code in this repository.
|
|||||||
живут в копии ниже маркера `<!-- conv:local -->` (META-22), который ставит
|
живут в копии ниже маркера `<!-- conv:local -->` (META-22), который ставит
|
||||||
сборщик. Заводить пустые местные разделы в каноне не нужно.
|
сборщик. Заводить пустые местные разделы в каноне не нужно.
|
||||||
|
|
||||||
|
## Граница темы
|
||||||
|
|
||||||
|
Пять вопросов, по которым тему проверяют на «не хапнули ли лишнего»; сначала
|
||||||
|
разрез темы, ось — потом.
|
||||||
|
|
||||||
|
- META-33: правило стоит в теме, чей вопрос оно решает. Тема — решение, а не
|
||||||
|
вещество: «время» проходит через несколько решений сразу, и правило о
|
||||||
|
колонках БД принадлежит схеме, а не времени.
|
||||||
|
- META-37: имя темы называет решение и адресата, а не роль части проекта:
|
||||||
|
`logging` и `client-logging`, но не `logging-backend`/`logging-frontend`.
|
||||||
|
- META-34: тема нужна потребителю целиком — подписка берёт её без остатка.
|
||||||
|
Если два правдоподобных потребителя хотят непересекающиеся части, между
|
||||||
|
ними и проходит граница.
|
||||||
|
- META-35: слой сужает базу, но не отменяет её. Приходится отменять — это не
|
||||||
|
слой, а другая тема; общим осталось слово, а не решение.
|
||||||
|
- META-36: вид приложения (веб-сервис, CLI, плейбуки, библиотека) называется
|
||||||
|
в области действия, если норма от него зависит. Осью он не является.
|
||||||
|
- META-20: норма исполнима без соседних тем.
|
||||||
|
|
||||||
## Выбор оси
|
## Выбор оси
|
||||||
|
|
||||||
Умирает при смене языка → `lang/<язык>/`. Умирает при смене инструмента,
|
Умирает при смене языка → `lang/<язык>/`. Умирает при смене инструмента,
|
||||||
хранилища или транспорта → `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`. Компонент пишется всегда, даже когда он
|
||||||
|
один. Директории компонентов различны — этим копии и разводятся.
|
||||||
|
|
||||||
## Оформление файла
|
## Оформление файла
|
||||||
|
|
||||||
@@ -174,14 +208,12 @@ code in this repository.
|
|||||||
|
|
||||||
## Состояние репозитория
|
## Состояние репозитория
|
||||||
|
|
||||||
- Тестов, линтеров и CI нет. `conv` — python3 CLI на одной stdlib; запускают
|
- Тестов, линтеров и CI здесь нет: репозиторий — данные, а не код. Проверяет
|
||||||
его из корня репозитория-потребителя (`~/projects/private/dev-conventions/`
|
их `convy suite check`, живущий в своём репозитории и ставящийся бинарём.
|
||||||
плюс команда).
|
- Модель копий, описанная в `README.md`, реализована в `convy`. Прежний
|
||||||
- Модель копий, описанная в `README.md`, согласована, но не реализована:
|
питоновский `conv` удалён вместе со своей моделью (зеркальное дерево,
|
||||||
`conv` собран под прежнюю (зеркальное дерево, именованные регионы,
|
именованные регионы, `origin_hash`). При расхождении обвязки с инструментом
|
||||||
`origin_hash`, команды `status`/`diff`/`push`). Сами конвенции к новой
|
истина — README, а не код.
|
||||||
модели приведены — регионов в каноне нет. При правке обвязки истина —
|
|
||||||
README, а не код `conv`.
|
|
||||||
- Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:`
|
- Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:`
|
||||||
в природе нет.
|
в природе нет.
|
||||||
- `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при
|
- `TODO.md` — площадка для обсуждения на будущее, а не принятые решения; при
|
||||||
|
|||||||
@@ -30,13 +30,31 @@ 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).
|
||||||
|
|
||||||
|
Пограничное правило разбирается признаком внешнего потребителя. Формат логов,
|
||||||
|
который собирает чужой агрегатор, — обязательство перед кем-то снаружи, и
|
||||||
|
место ему в спеке. Если от правила зависит только автор следующего патча —
|
||||||
|
это конвенция.
|
||||||
|
|
||||||
## Оформление
|
## Оформление
|
||||||
|
|
||||||
Имя файла повторяет имя темы: `app-directories.md`. Правилом это не
|
Имя файла повторяет имя темы: `app-directories.md`. Правилом это не
|
||||||
@@ -63,6 +81,23 @@ prefix: META
|
|||||||
канон, или документ, переставший быть копией, — тогда `origin:` из шапки
|
канон, или документ, переставший быть копией, — тогда `origin:` из шапки
|
||||||
убирают.
|
убирают.
|
||||||
|
|
||||||
|
## Как проверить границу темы
|
||||||
|
|
||||||
|
Готовая тема проходится по шести вопросам; на каждый отвечает своё правило:
|
||||||
|
|
||||||
|
- на какой вопрос отвечает правило — и тот ли это вопрос, что у темы
|
||||||
|
(META-33);
|
||||||
|
- нужна ли тема правдоподобному потребителю целиком (META-34);
|
||||||
|
- слой сужает базу или отменяет её (META-35);
|
||||||
|
- зависит ли норма от вида приложения и назван ли он (META-36);
|
||||||
|
- названа ли тема решением и адресатом, а не ролью части проекта (META-37);
|
||||||
|
- исполнима ли норма, если соседних тем в репозитории нет (META-20).
|
||||||
|
|
||||||
|
Расхождение на любом из них означает, что граница проходит не там, где
|
||||||
|
нарисована: тема собрана вокруг вещества, склеила два решения или молча
|
||||||
|
предполагает вид приложения. Чинится это разрезом темы или областью
|
||||||
|
действия, а не смягчением нормы.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
|
|
||||||
### META-1. Одна конвенция — один файл
|
### META-1. Одна конвенция — один файл
|
||||||
@@ -75,6 +110,101 @@ prefix: META
|
|||||||
дорого: перенос правила в другой файл — это новый префикс и новая
|
дорого: перенос правила в другой файл — это новый префикс и новая
|
||||||
нумерация, поэтому после разреза все внешние ссылки обходят руками.
|
нумерация, поэтому после разреза все внешние ссылки обходят руками.
|
||||||
|
|
||||||
|
### 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. Тема объявляется в шапке файла и стоит в манифесте набора
|
### META-28. Тема объявляется в шапке файла и стоит в манифесте набора
|
||||||
|
|
||||||
**ДОЛЖЕН.** Шапка несёт ключ `topic:` с именем темы, и это имя стоит в
|
**ДОЛЖЕН.** Шапка несёт ключ `topic:` с именем темы, и это имя стоит в
|
||||||
|
|||||||
@@ -602,6 +602,10 @@ XMIG-6 не соблюдается в легаси-таблицах `show_histor
|
|||||||
|
|
||||||
- шапка файла несёт имя темы, и это имя стоит в манифесте набора — среди
|
- шапка файла несёт имя темы, и это имя стоит в манифесте набора — среди
|
||||||
живых, а не среди выбывших;
|
живых, а не среди выбывших;
|
||||||
|
- ось слоя объявлена в шапке, а не выведена из пути; у одной темы не больше
|
||||||
|
одного слоя без ключей оси — базовый слой единственный;
|
||||||
|
- если директории осей используются, объявленное в шапке совпадает с путём:
|
||||||
|
расхождение означает переезд файла без правки шапки;
|
||||||
- имя темы, названное в ссылке или в подписке потребителя, тоже разрешается по
|
- имя темы, названное в ссылке или в подписке потребителя, тоже разрешается по
|
||||||
манифесту: ссылка на снятую тему не проходит молча;
|
манифесту: ссылка на снятую тему не проходит молча;
|
||||||
- префиксы локальных правил копии начинаются на `X`;
|
- префиксы локальных правил копии начинаются на `X`;
|
||||||
@@ -625,6 +629,10 @@ XMIG-6 не соблюдается в легаси-таблицах `show_histor
|
|||||||
- примеры иллюстрируют норму и не расширяют её: деталь примера не читается
|
- примеры иллюстрируют норму и не расширяют её: деталь примера не читается
|
||||||
как требование.
|
как требование.
|
||||||
|
|
||||||
|
Проверки выше — про запись правила. Граница самой темы (не собрала ли она
|
||||||
|
два решения сразу, нужна ли потребителю целиком) языку не принадлежит: её
|
||||||
|
вопросы стоят в документе, которым набор ведёт себя.
|
||||||
|
|
||||||
## Версия языка
|
## Версия языка
|
||||||
|
|
||||||
Номер версии называется в каждой конвенции, поэтому он двигается, когда
|
Номер версии называется в каждой конвенции, поэтому он двигается, когда
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
|
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
|
||||||
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
|
||||||
| [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
|
| [READING.md](READING.md) | как читать конвенцию: то, что едет к потребителю |
|
||||||
| [manifest.toml](manifest.toml) | манифест набора: язык, темы, префиксы правил |
|
| `.conventions-suite.toml` | манифест набора: язык, темы, префиксы правил |
|
||||||
| `conv` | сборка копий |
|
|
||||||
|
|
||||||
К потребителю едет содержимое `conventions/` и один файл обвязки —
|
К потребителю едет содержимое `conventions/` и один файл обвязки —
|
||||||
`READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
|
`READING.md`; остальная обвязка остаётся в каноне. Самодостаточность копии это
|
||||||
@@ -53,13 +52,27 @@ conventions/
|
|||||||
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
||||||
```
|
```
|
||||||
|
|
||||||
Оси — раскладка **канона**; в репозитории копия лежит плоско, файлом на
|
Ось файл **объявляет в шапке**, а не наследует от директории (META-38):
|
||||||
тему. Пути файлов даются относительно `conventions/`
|
|
||||||
(`arch/db-identifiers.md`) и адресуют исходник канона, а не место в копии.
|
```yaml
|
||||||
На **правила** ссылаются идентификатором без пути: `KEYS-5`. Префикс
|
topic: logging
|
||||||
уникален по всему канону (он перечислен в манифесте набора), поэтому
|
prefix: SLOG
|
||||||
идентификатор не зависит ни от оси, ни от того, как собран файл у
|
lang: go
|
||||||
потребителя.
|
```
|
||||||
|
|
||||||
|
Ключей оси нет — базовый слой темы. Слова те же, что в подписке потребителя
|
||||||
|
(`lang`, `stack`), так что переводить между двумя сторонами нечего. Дерево
|
||||||
|
директорий повторяет объявленное для человека и остаётся раскладкой
|
||||||
|
**канона**: в репозитории копия лежит плоско, файлом на тему. Пути файлов
|
||||||
|
даются относительно `conventions/` (`arch/db-identifiers.md`) и адресуют
|
||||||
|
исходник канона, а не место в копии. На **правила** ссылаются идентификатором
|
||||||
|
без пути: `KEYS-5`. Префикс уникален по всему канону (он перечислен в
|
||||||
|
манифесте набора), поэтому идентификатор не зависит ни от оси, ни от того, как
|
||||||
|
собран файл у потребителя.
|
||||||
|
|
||||||
|
Объявление вдобавок выражает то, чего дерево не умеет: слой, осмысленный
|
||||||
|
только при совпадении языка и инструмента сразу (`lang: go` и `stack: slog` в
|
||||||
|
одной шапке).
|
||||||
|
|
||||||
Тест — по тому, замена чего убивает правило:
|
Тест — по тому, замена чего убивает правило:
|
||||||
|
|
||||||
@@ -84,6 +97,31 @@ 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), и тем, что
|
||||||
|
резать нужно правильной стороной: база остаётся в исходном файле со своими
|
||||||
|
идентификаторами, а наружу уезжает специфичное. Если второй язык виден
|
||||||
|
заранее, дешевле сразу разложить по осям.
|
||||||
|
|
||||||
## Темы
|
## Темы
|
||||||
|
|
||||||
**Тема — набор правил об одном фокусе разработки:** время, конфигурация,
|
**Тема — набор правил об одном фокусе разработки:** время, конфигурация,
|
||||||
@@ -103,12 +141,19 @@ prefix: KEYS
|
|||||||
документ, как бы ни назывались их файлы. Имя файла повторяет тему из
|
документ, как бы ни назывались их файлы. Имя файла повторяет тему из
|
||||||
удобства, но истина — в шапке.
|
удобства, но истина — в шапке.
|
||||||
|
|
||||||
Темы перечислены в манифесте набора — [`manifest.toml`](manifest.toml),
|
Темы перечислены в манифесте набора — `.conventions-suite.toml`,
|
||||||
секция `[topics.live]`: имя и однострочное описание. Имя темы не
|
секция `[topics.live]`: имя и однострочное описание. Имя темы не
|
||||||
переиспользуется по той же причине, что и префикс: оно живёт в чужих
|
переиспользуется по той же причине, что и префикс: оно живёт в чужих
|
||||||
репозиториях — в шапке `origin:` каждой копии, в подписке манифеста, в тексте
|
репозиториях — в шапке `origin:` каждой копии, в подписке манифеста, в тексте
|
||||||
ссылок, — и выданное второй теме начинает указывать на другой набор правил.
|
ссылок, — и выданное второй теме начинает указывать на другой набор правил.
|
||||||
|
|
||||||
|
Раз имя вечно, называют тему **решением и его адресатом**, а не ролью части
|
||||||
|
конкретного проекта (META-37). Логи сервера и логи браузера — это `logging` и
|
||||||
|
`client-logging`, а не `logging-backend` и `logging-frontend`: роль
|
||||||
|
принадлежит сегодняшнему устройству одного репозитория и молча начинает врать,
|
||||||
|
а суффиксная пара вдобавок навязывает чтение «две разновидности одного», хотя
|
||||||
|
по границе темы это разные решения — общего у них три правила из сорока.
|
||||||
|
|
||||||
## Префиксы
|
## Префиксы
|
||||||
|
|
||||||
Каждый файл канона объявляет в шапке свой префикс правил:
|
Каждый файл канона объявляет в шапке свой префикс правил:
|
||||||
@@ -118,11 +163,17 @@ prefix: KEYS
|
|||||||
```
|
```
|
||||||
|
|
||||||
Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
|
Четыре заглавные латинские буквы, уникальные по всему канону; перечислены в
|
||||||
манифесте набора, секция `[prefixes.live]`. Префикс выбирается под файл, а не
|
манифесте набора, секция `[prefixes.live]`, путём **от корня репозитория**, а
|
||||||
выводится по формуле, и не переиспользуется никогда. Правила адресуются
|
не от `conventions/` — манифест покрывает и обвязку тоже. Префикс выбирается
|
||||||
идентификатором `KEYS-5` — без пути к файлу. Подробности формы —
|
под файл, а не выводится по формуле, и не переиспользуется никогда. Правила
|
||||||
|
адресуются идентификатором `KEYS-5` — без пути к файлу. Подробности формы —
|
||||||
`LANGUAGE.md`.
|
`LANGUAGE.md`.
|
||||||
|
|
||||||
|
`GUIDE.md` тоже несёт префикс и тоже проверяется как конвенция: правила в нём
|
||||||
|
записаны тем же языком и цитируются по номерам. Темы у него нет — подписаться
|
||||||
|
на него нельзя, к потребителю он не едет, — и манифест называет его отдельным
|
||||||
|
ключом `governance`, чтобы конвенция, потерявшая `topic`, не сошла за него.
|
||||||
|
|
||||||
Буква `X` в начале префикса зарезервирована за репозиториями: канон её не
|
Буква `X` в начале префикса зарезервирована за репозиториями: канон её не
|
||||||
занимает никогда, а локальные правила потребителя берут префиксы только на
|
занимает никогда, а локальные правила потребителя берут префиксы только на
|
||||||
неё (`XTIM`, `XLOG`). Так столкновение локального префикса с будущим
|
неё (`XTIM`, `XLOG`). Так столкновение локального префикса с будущим
|
||||||
@@ -143,24 +194,60 @@ extends: arch/db-identifiers.md
|
|||||||
репозиторий на базу просто не подписан.
|
репозиторий на базу просто не подписан.
|
||||||
|
|
||||||
`extends` — документация связи, а не механизм: за тем, чтобы база лежала
|
`extends` — документация связи, а не механизм: за тем, чтобы база лежала
|
||||||
рядом, никто не следит.
|
рядом, никто не следит. С объявленной осью база к тому же находится сама —
|
||||||
|
это слой той же темы без ключей `lang` и `stack`, — так что ключ остаётся
|
||||||
|
подсказкой человеку и ничего не выбирает.
|
||||||
|
|
||||||
|
## Компонент — адресат сборки
|
||||||
|
|
||||||
|
Подписка принадлежит репозиторию, а собранный документ адресован не
|
||||||
|
репозиторию, а куску кода. Пока проект однороден, разницы нет; два языка её
|
||||||
|
проявляют: `lang = ["go", "javascript"]` склеили бы в один файл го-слой и
|
||||||
|
js-слой, из которых к правимому коду относится ровно половина.
|
||||||
|
|
||||||
|
**Компонент — область репозитория, где все выбранные слои действуют
|
||||||
|
одновременно.** `sqlite` и `postgres` в теме схемы действуют вместе — разные
|
||||||
|
таблицы одного сервиса; го-слой и js-слой не действуют вместе никогда, потому
|
||||||
|
что строка кода написана на чём-то одном. Компонент поэтому совпадает с тем,
|
||||||
|
у чего один язык, один набор инструментов и один вид приложения (META-36).
|
||||||
|
|
||||||
|
Уровней в модели становится три: набор → проект → компонент. Сборка не
|
||||||
|
меняется — та же линейка «база → язык → стек», прогнанная по разу на
|
||||||
|
компонент.
|
||||||
|
|
||||||
## Копия в репозитории
|
## Копия в репозитории
|
||||||
|
|
||||||
Копия плоская: **один файл на тему**, слои осей идут внутри него секциями в
|
Копия плоская: **один файл на тему**, слои осей идут внутри него секциями в
|
||||||
порядке `arch` → язык → стек. Пути канона в копии не воспроизводятся.
|
порядке база → язык → стек. Пути канона в копии не воспроизводятся. Каждый
|
||||||
|
компонент получает свою директорию:
|
||||||
|
|
||||||
```
|
```
|
||||||
docs/conventions/
|
.conventions.toml
|
||||||
|
backend/docs/conventions/
|
||||||
README.md собственный, не собирается
|
README.md собственный, не собирается
|
||||||
READING.md как читать конвенцию — приезжает из канона
|
READING.md как читать конвенцию — приезжает из канона
|
||||||
|
logging.md база + lang/go + stack/slog
|
||||||
time.md arch/time.md + lang/go/time.md
|
time.md arch/time.md + lang/go/time.md
|
||||||
db-identifiers.md arch/db-identifiers.md + lang/go/db-identifiers.md
|
web/docs/conventions/
|
||||||
app-directories.md arch/… + stack/ansible/…
|
READING.md
|
||||||
|
client-logging.md база + lang/javascript + stack/express
|
||||||
```
|
```
|
||||||
|
|
||||||
Так конвенция остаётся самодостаточным документом: тот, кто проверяет по ней
|
Так конвенция остаётся самодостаточным документом: тот, кто проверяет по ней
|
||||||
код — человек или агент, — читает один файл и не собирает тему из трёх мест.
|
код — человек или агент, — читает один файл и не собирает тему из трёх мест.
|
||||||
|
При одном компоненте это ровно прежняя раскладка — `docs/conventions/` в
|
||||||
|
корне.
|
||||||
|
|
||||||
|
Директории компонентов различны, и это единственное, что разводит копии:
|
||||||
|
`logging.md` двух компонентов — разные файлы с одинаковым `origin: logging`,
|
||||||
|
и какой из них какой, сборщик знает по манифесту, а читатель — по пути.
|
||||||
|
Локальные части у них независимы, ради чего всё и затевается: правило,
|
||||||
|
механизированное линтером в go-компоненте, в js-компоненте не механизировано,
|
||||||
|
и один общий файл этого не записал бы.
|
||||||
|
|
||||||
|
`READING.md` лежит рядом с копиями, то есть по одному на компонент. Файл
|
||||||
|
генерируемый, а директория с правилами обязана объяснять себя тому, кто в неё
|
||||||
|
попал.
|
||||||
|
|
||||||
Имена в директории делятся на три вида: `README.md` принадлежит репозиторию и
|
Имена в директории делятся на три вида: `README.md` принадлежит репозиторию и
|
||||||
сборщик его не трогает, `READING.md` принадлежит канону и перезаписывается
|
сборщик его не трогает, `READING.md` принадлежит канону и перезаписывается
|
||||||
@@ -229,8 +316,8 @@ MIGR-6 не соблюдается в `show_history`, `queue`: составны
|
|||||||
|
|
||||||
| Файл | Где лежит | Что описывает |
|
| Файл | Где лежит | Что описывает |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `manifest.toml` | в наборе | сам набор: темы и префиксы правил |
|
| `.conventions-suite.toml` | в наборе | сам набор: язык, темы, префиксы правил |
|
||||||
| `.conventions.toml` | в проекте | подключение: откуда копии, какие темы, язык, стек |
|
| `.conventions.toml` | в проекте | подключение: откуда копии, компоненты и их подписки |
|
||||||
|
|
||||||
Манифест набора — единственное место, где перечислены оба идентификатора
|
Манифест набора — единственное место, где перечислены оба идентификатора
|
||||||
канона; правила у них общие, поэтому и файл один. Манифест подключения
|
канона; правила у них общие, поэтому и файл один. Манифест подключения
|
||||||
@@ -239,17 +326,29 @@ MIGR-6 не соблюдается в `show_history`, `queue`: составны
|
|||||||
```toml
|
```toml
|
||||||
source = "ssh://git@git.vakhrushev.me:2222/av/dev-conventions.git"
|
source = "ssh://git@git.vakhrushev.me:2222/av/dev-conventions.git"
|
||||||
|
|
||||||
|
[components.backend]
|
||||||
|
dir = "backend/docs/conventions"
|
||||||
lang = ["go"]
|
lang = ["go"]
|
||||||
stack = ["sqlite", "htmx"]
|
stack = ["slog", "sqlite"]
|
||||||
|
topics = ["logging", "errors", "time"]
|
||||||
|
|
||||||
topics = ["time", "config", "db-identifiers"]
|
[components.web]
|
||||||
|
dir = "web/docs/conventions"
|
||||||
|
lang = ["javascript"]
|
||||||
|
stack = ["express"]
|
||||||
|
topics = ["client-logging"]
|
||||||
```
|
```
|
||||||
|
|
||||||
`lang` и `stack` выбирают строку разреженной матрицы: файл темы собирает
|
`lang` и `stack` выбирают строку разреженной матрицы: файл темы собирает
|
||||||
только те слои, которые репозиторию подходят. `topics` — подписка, именами из
|
только те слои, которые компоненту подходят, и совпадают со словами, которыми
|
||||||
манифеста набора; списка подписчиков у канона по-прежнему нет, список подписок
|
слой объявил свою ось. `topics` — подписка, именами из манифеста набора;
|
||||||
есть
|
списка подписчиков у канона по-прежнему нет, список подписок есть только у
|
||||||
только у потребителя.
|
потребителя.
|
||||||
|
|
||||||
|
Компонент пишется всегда, даже когда он один: сокращённая плоская форма
|
||||||
|
сэкономила бы три строки и завела бы второй способ сказать то же самое.
|
||||||
|
Имя компонента при этом не служебное — им сборщик отвечает, что и куда
|
||||||
|
собрал. У плоского набора `lang` и `stack` в компоненте просто отсутствуют.
|
||||||
|
|
||||||
Как именно инструмент добирается до канона — путь на диске, git, HTTP —
|
Как именно инструмент добирается до канона — путь на диске, git, HTTP —
|
||||||
дело инструмента, а не модели. Манифест отвечает на два вопроса: откуда это
|
дело инструмента, а не модели. Манифест отвечает на два вопроса: откуда это
|
||||||
@@ -265,20 +364,37 @@ topics = ["time", "config", "db-identifiers"]
|
|||||||
любого другого документа. Это главный канал тихого дрейфа, поэтому
|
любого другого документа. Это главный канал тихого дрейфа, поэтому
|
||||||
`AGENTS.md` каждого потребителя должен явно говорить:
|
`AGENTS.md` каждого потребителя должен явно говорить:
|
||||||
|
|
||||||
> Файлы в `docs/conventions/` с шапкой `origin:` — копии из канона
|
> Файлы с шапкой `origin:` в директориях конвенций (пути — в
|
||||||
> `dev-conventions`. Репозиторное пишется только ниже `<!-- conv:local -->`;
|
> `.conventions.toml`) — копии из канона `dev-conventions`. Репозиторное
|
||||||
> всё выше маркера перезаписывается при обновлении. Своё правило — с
|
> пишется только ниже `<!-- conv:local -->`; всё выше маркера
|
||||||
> префиксом на `X`.
|
> перезаписывается при обновлении. Своё правило — с префиксом на `X`.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
|
Копии собирает `convy` — отдельный инструмент, живущий в своём репозитории и
|
||||||
|
ставящийся бинарём. Запускают его из корня репозитория-потребителя:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conv list # какие темы есть в каноне
|
convy init --source <ссылка на канон> --component backend \
|
||||||
conv add time # добавить тему в манифест и собрать файл
|
--dir docs/conventions --lang go
|
||||||
conv pull # пересобрать всё, что перечислено в манифесте
|
convy add time # подписаться на тему и собрать файл
|
||||||
# (и обновить READING.md рядом с копиями)
|
convy add time --for backend # то же, когда компонентов несколько
|
||||||
|
convy pull # пересобрать всё, что перечислено в манифесте
|
||||||
|
# (и обновить READING.md рядом с копиями)
|
||||||
|
convy pull --for web # только один компонент
|
||||||
|
convy sync # подвести раскладку файлов под манифест
|
||||||
|
convy list # что подключено и что ещё есть в каноне
|
||||||
|
convy check # проверить форму того, что лежит здесь
|
||||||
```
|
```
|
||||||
|
|
||||||
|
При одном компоненте `--for` не нужен. При нескольких команда без него не
|
||||||
|
угадывает, а отказывает и перечисляет имена.
|
||||||
|
|
||||||
|
Манифест подключения правится и руками — это данные, а не текст с
|
||||||
|
комментариями. Что бы в нём ни поменяли, раскладку под него подводит `convy
|
||||||
|
sync`: чего не хватает — соберёт, что осиротело — уберёт, а копию с локальной
|
||||||
|
частью не тронет и назовёт.
|
||||||
|
|
||||||
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
|
Отчёт о том, что изменилось, отдельной командой не выдаётся: после `pull`
|
||||||
его показывает `git diff`, а решение — принять, поправить или откатить —
|
его показывает `git diff`, а решение — принять, поправить или откатить —
|
||||||
принимает человек перед коммитом.
|
принимает человек перед коммитом.
|
||||||
@@ -287,11 +403,9 @@ conv pull # пересобрать всё, что переч
|
|||||||
репозитории, переносится в канон руками: это редкая операция, и её цена —
|
репозитории, переносится в канон руками: это редкая операция, и её цена —
|
||||||
не аргумент против того, чтобы направление оставалось односторонним.
|
не аргумент против того, чтобы направление оставалось односторонним.
|
||||||
|
|
||||||
Запускать из корня репозитория:
|
Сам канон ведут те же командой под `suite`: `convy suite add` заводит
|
||||||
|
конвенцию, `convy suite rule` дописывает правило, `convy suite retire`
|
||||||
```bash
|
снимает, `convy suite check` проверяет целостность набора.
|
||||||
~/projects/private/dev-conventions/conv pull
|
|
||||||
```
|
|
||||||
|
|
||||||
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
|
Обёртка в раннере репозитория (`inv conventions -- pull` для ansible,
|
||||||
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
|
`task conventions -- pull` для Go) — тонкий проброс аргументов, чтобы
|
||||||
@@ -311,10 +425,11 @@ conv pull # пересобрать всё, что переч
|
|||||||
|
|
||||||
## Состояние
|
## Состояние
|
||||||
|
|
||||||
Модель выше — согласованная, а не реализованная. `conv` пока собран под
|
Модель выше реализована в `convy`: сборка копий, отбор слоёв по объявленной
|
||||||
прежнюю: зеркальное дерево копий вместо плоского, именованные регионы
|
оси, маркер локальной части, `READING.md` рядом с копиями, проверка
|
||||||
`<!-- local:имя -->` вместо одного маркера, `origin_hash` в шапке и команды
|
целостности набора. Прежний питоновский `conv` — с зеркальным деревом,
|
||||||
`status`, `diff`, `push`; `READING.md` рядом с копиями он тоже пока не
|
именованными регионами и `origin_hash` — удалён вместе со своей моделью.
|
||||||
кладёт. Сами конвенции уже приведены к новой модели — именованных регионов в
|
|
||||||
каноне нет. Ни один репозиторий-потребитель не
|
Ни один репозиторий-потребитель ещё не подключён: копий с шапкой `origin:` в
|
||||||
подключён, поэтому переход никого не ломает.
|
природе нет. Пока это так, непроверенным остаётся главное — как всё это живёт
|
||||||
|
в чужом репозитории через полгода после первой сборки.
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
решения. Закрытый вопрос отсюда удаляется — принятое решение живёт в
|
решения. Закрытый вопрос отсюда удаляется — принятое решение живёт в
|
||||||
`README.md`, `GUIDE.md` или `LANGUAGE.md`, а не в этом файле.
|
`README.md`, `GUIDE.md` или `LANGUAGE.md`, а не в этом файле.
|
||||||
|
|
||||||
Две секции: сначала язык и подход, потом канон с тулингом.
|
Вопросы про инструмент здесь не живут — они собраны в его собственном
|
||||||
|
репозитории.
|
||||||
|
|
||||||
|
Две секции: сначала язык и подход, потом сам набор и подключение.
|
||||||
|
|
||||||
# Язык и подход
|
# Язык и подход
|
||||||
|
|
||||||
@@ -64,48 +67,25 @@ API, а норму при этом нельзя поправить, не зад
|
|||||||
сущность и ещё одна синхронизация; при одном наборе лечение выходит хуже
|
сущность и ещё одна синхронизация; при одном наборе лечение выходит хуже
|
||||||
болезни.
|
болезни.
|
||||||
|
|
||||||
# Канон, тулинг, подключение
|
# Канон и подключение
|
||||||
|
|
||||||
## 4. Тулинг: две разные задачи в одном `conv`
|
## 4. Значения осей нигде не зарегистрированы
|
||||||
|
|
||||||
Сейчас в `conv` смешаны две категории работы, и они расходятся по всему —
|
Ось теперь объявлена в шапке (META-38), но её значения не сверяются ни с чем:
|
||||||
по частоте запуска, по тому, кто запускает, и по тому, что считается
|
`lang: golang` вместо `lang: go` соберётся молча — слой просто не попадёт ни в
|
||||||
провалом.
|
одну копию. Это ровно та болезнь, от которой лечили тему (META-28): объявление
|
||||||
|
без реестра проверяется только глазами.
|
||||||
|
|
||||||
**Целостность канона.** Префиксы уникальны и не переиспользованы, шапка
|
Напрашивается секция в `.conventions-suite.toml` рядом с `[topics.live]` и
|
||||||
совпадает с манифестом, у каждого правила модальность и блок ПОЧЕМУ, ссылки
|
`[prefixes.live]` — перечень живых языков и стеков с однострочным описанием, и
|
||||||
разрешаются, префикс чужой темы не лезет в норму (META-20), путей канона в
|
те же правила выбытия. Против: третий реестр в манифесте, а значений сегодня
|
||||||
тексте нет (META-21), строка о версии языка на месте. Запускается в каноне,
|
три (`go`, `ansible`, `htmx`). За: словарь общий у двух сторон — им же
|
||||||
при каждой правке, провал — это ошибка. Логика уже написана и много раз
|
потребитель пишет `lang` и `stack` в своём компоненте, и опечатка там стоит
|
||||||
прогнана руками, но живёт в скретчпаде, а не в репозитории.
|
столько же.
|
||||||
|
|
||||||
**Установка в проект.** Манифест `.conventions.toml`, сборка файла темы из
|
Заодно решается судьба `extends:`: с объявленной осью база находится сама —
|
||||||
слоёв (`arch` → язык → стек), сохранение при пересборке всего, что ниже
|
это слой той же темы без ключей оси, — так что ключ остался подсказкой
|
||||||
маркера, `READING.md` рядом с копиями, предупреждение о висячих ссылках на
|
человеку и кандидат на снятие.
|
||||||
неподписанные темы. Запускается
|
|
||||||
в репозитории-потребителе, изредка, провал — это чаще «посмотри глазами»,
|
|
||||||
чем «ошибка». Отчёта «канон ушёл вперёд» здесь нет: его делает `git diff`
|
|
||||||
после пересборки.
|
|
||||||
|
|
||||||
Что обсудить:
|
|
||||||
|
|
||||||
- Разделять ли на два исполняемых файла, или хватит подкоманд с честной
|
|
||||||
границей внутри.
|
|
||||||
- Валидацию канона стоит ли отдать агенту скиллом вместо (или вдобавок к)
|
|
||||||
скрипту: часть проверок формулируется как «прочитай и скажи, самодостаточна
|
|
||||||
ли норма» — механически это не берётся, а агентом берётся.
|
|
||||||
- Куда в этой раскладке ложится запаркованное предупреждение о висячих
|
|
||||||
ссылках: это установка, а не целостность, но список подписок ему нужен из
|
|
||||||
манифеста.
|
|
||||||
|
|
||||||
Список проверок теперь реализуем целиком: граница правила определена,
|
|
||||||
нумерация сплошная, снятое правило остаётся заглушкой — данных со стороны
|
|
||||||
языка проверке хватает.
|
|
||||||
|
|
||||||
Перед тем как переписывать, стоит посмотреть на два готовых прототипа:
|
|
||||||
дистрибуцию пакетов Vale (`.vale.ini` → `vale sync` → `styles/`) как образец
|
|
||||||
манифеста и `vendir.yml` — как пример того, где проходит граница между «чего
|
|
||||||
хочу» и «что получил».
|
|
||||||
|
|
||||||
## 5. Пары слоёв и темы без базы
|
## 5. Пары слоёв и темы без базы
|
||||||
|
|
||||||
@@ -128,28 +108,37 @@ API, а норму при этом нельзя поправить, не зад
|
|||||||
- Вынос арх-ядра из `errors` и `logging` закроет две хрупкие ссылки из
|
- Вынос арх-ядра из `errors` и `logging` закроет две хрупкие ссылки из
|
||||||
`web-ui` (стек) в go-слой — единственные ссылки стек → язык в каноне.
|
`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` /
|
репозиториях записано по факту; обёртка в раннере (`inv conventions` /
|
||||||
`task conventions`, единый интерфейс команд у трёх ansible-репозиториев);
|
`task conventions`, единый интерфейс команд у трёх ansible-репозиториев);
|
||||||
строка в `AGENTS.md` каждого потребителя про то, что файлы в
|
строка в `AGENTS.md` каждого потребителя про то, что файлы в директориях
|
||||||
`docs/conventions/` — копии.
|
конвенций — копии. Компонент у обоих кандидатов один, но записывается явно.
|
||||||
|
|
||||||
## 7. Тулинг на Go, живущий независимо
|
|
||||||
|
|
||||||
Сейчас `conv` — питоновский скрипт внутри канона, то есть тулинг и данные в
|
|
||||||
одном репозитории и правятся одним движением. Мысль: вынести в отдельный
|
|
||||||
Go-бинарь со своим релизным циклом, ставить через `eget` (механизм уже есть
|
|
||||||
в pet-project-server).
|
|
||||||
|
|
||||||
Что за этим стоит помимо вкуса: независимый бинарь физически не даёт править
|
|
||||||
инструмент «заодно» с правкой конвенции, работает против **любого** канона и
|
|
||||||
любого потребителя — что прямо требуется вопросом 3, — и снимает питон из
|
|
||||||
зависимостей репозиториев-потребителей.
|
|
||||||
|
|
||||||
Порядок обратный ожидаемому: пока вопрос 3 не сделан, инструмент всё равно
|
|
||||||
работает против одного конкретного канона, и независимый релизный цикл ему
|
|
||||||
нечего обслуживать. Сначала 3, потом 7. Разделение из вопроса 4 при этом
|
|
||||||
дешевле заложить сразу, чем отпиливать потом.
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: config
|
topic: config
|
||||||
prefix: GCFG
|
prefix: GCFG
|
||||||
|
lang: go
|
||||||
extends: arch/config.md
|
extends: arch/config.md
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: db-identifiers
|
topic: db-identifiers
|
||||||
prefix: GKEY
|
prefix: GKEY
|
||||||
|
lang: go
|
||||||
extends: arch/db-identifiers.md
|
extends: arch/db-identifiers.md
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: db-schema
|
topic: db-schema
|
||||||
prefix: MIGR
|
prefix: MIGR
|
||||||
|
lang: go
|
||||||
---
|
---
|
||||||
|
|
||||||
# Схема и миграции (SQLite, Go)
|
# Схема и миграции (SQLite, Go)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: errors
|
topic: errors
|
||||||
prefix: GERR
|
prefix: GERR
|
||||||
|
lang: go
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ошибки
|
# Ошибки
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: logging
|
topic: logging
|
||||||
prefix: SLOG
|
prefix: SLOG
|
||||||
extends: arch/time.md
|
lang: go
|
||||||
---
|
---
|
||||||
|
|
||||||
# Логирование
|
# Логирование
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: time
|
topic: time
|
||||||
prefix: GTIM
|
prefix: GTIM
|
||||||
|
lang: go
|
||||||
extends: arch/time.md
|
extends: arch/time.md
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: app-directories
|
topic: app-directories
|
||||||
prefix: ANSD
|
prefix: ANSD
|
||||||
|
stack: ansible
|
||||||
extends: arch/app-directories.md
|
extends: arch/app-directories.md
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
topic: web-ui
|
topic: web-ui
|
||||||
prefix: HTMX
|
prefix: HTMX
|
||||||
|
stack: htmx
|
||||||
---
|
---
|
||||||
|
|
||||||
# Веб-UI на htmx
|
# Веб-UI на htmx
|
||||||
|
|||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
# Манифест набора конвенций.
|
|
||||||
#
|
|
||||||
# Манифестов в модели два, и они отвечают на разные вопросы:
|
|
||||||
#
|
|
||||||
# - этот, в наборе, описывает сам набор: какие в нём темы и какие префиксы
|
|
||||||
# правил заняты;
|
|
||||||
# - `.conventions.toml` в репозитории-потребителе описывает подключение:
|
|
||||||
# откуда взяты копии, какие темы выбраны, какие язык и стек.
|
|
||||||
#
|
|
||||||
# Оба идентификатора набора — тема и префикс — живут здесь, потому что
|
|
||||||
# правила у них общие: объявляются в шапке файла, сверяются с манифестом,
|
|
||||||
# не переиспользуются никогда, а снятые уходят в свой раздел `retired`
|
|
||||||
# вместе с причиной и датой.
|
|
||||||
|
|
||||||
# ─── Язык записи ────────────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Набор объявляет версию языка, на котором записаны его правила, и два
|
|
||||||
# документа о нём. Полное описание остаётся у автора; в копию рядом с
|
|
||||||
# конвенциями едет короткое `READING.md` — то, что нужно читателю правил, без
|
|
||||||
# ссылок на правила ведения набора.
|
|
||||||
|
|
||||||
[language]
|
|
||||||
version = 1
|
|
||||||
description = "LANGUAGE.md"
|
|
||||||
reading = "READING.md"
|
|
||||||
|
|
||||||
# ─── Темы ───────────────────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Тема — набор правил об одном фокусе разработки: время, конфигурация, схема
|
|
||||||
# БД. Имя записывается латиницей; рекомендуется нижний kebab-case, но годится
|
|
||||||
# любой идентификатор, пригодный для имени файла.
|
|
||||||
#
|
|
||||||
# Тема — единица подписки и единица сборки: потребитель перечисляет темы в
|
|
||||||
# своём манифесте, а сборщик складывает в один файл все слои темы в порядке
|
|
||||||
# arch → язык → стек. Слои узнают друг друга по объявленному имени, а не по
|
|
||||||
# имени файла: файл конвенции несёт тему в шапке (`topic: time`).
|
|
||||||
#
|
|
||||||
# Имя темы не переименовывается и не переиспользуется: на тему ссылаются
|
|
||||||
# словом — из текста конвенций («конвенция `logging`»), из подписки в
|
|
||||||
# манифесте потребителя, из шапки `origin:` каждой копии, — и такая ссылка
|
|
||||||
# обязана продолжать указывать на тот же набор правил. Тема живёт, пока в
|
|
||||||
# `conventions/` есть хотя бы один её слой.
|
|
||||||
#
|
|
||||||
# Описание — одна строка о том, про что тема: из него собирается таблица в
|
|
||||||
# README директории конвенций у потребителя (META-18).
|
|
||||||
|
|
||||||
[topics.live]
|
|
||||||
app-directories = "категории директорий приложения и что в каждой лежит"
|
|
||||||
config = "конфигурация: файл, валидация, секреты"
|
|
||||||
db-identifiers = "идентификаторы сущностей: вид ключа, генерация, границы"
|
|
||||||
db-schema = "схема БД и миграции: типы колонок, форма изменения"
|
|
||||||
errors = "ошибки: обёртки, границы трансляции, паники"
|
|
||||||
logging = "логирование: уровни, структура записи, что не логируем"
|
|
||||||
time = "время: хранение, зоны, форматы, календарные границы"
|
|
||||||
web-ui = "веб-UI: партиалы, свопы, поллинг"
|
|
||||||
|
|
||||||
[topics.retired]
|
|
||||||
# Пусто. Сюда попадают имена снятых и переименованных тем вместе с причиной
|
|
||||||
# и датой, чтобы их нельзя было выдать другой теме.
|
|
||||||
|
|
||||||
# ─── Префиксы правил ────────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Префикс — четыре заглавные латинские буквы, уникальные по всему набору. Он
|
|
||||||
# выбирается под файл, а не выводится по формуле: префикс нужен, чтобы по нему
|
|
||||||
# искать, а не чтобы его разбирать. Поэтому подходящее слово лучше
|
|
||||||
# закономерности.
|
|
||||||
#
|
|
||||||
# Правила:
|
|
||||||
#
|
|
||||||
# - префикс не переименовывается и не переиспользуется никогда — ссылка
|
|
||||||
# из чужого репозитория обязана продолжать указывать на то же место;
|
|
||||||
# - при удалении или разделении файла префикс уходит в retired, а не
|
|
||||||
# освобождается;
|
|
||||||
# - переезд файла между осями префикс не меняет: идентификатор правила
|
|
||||||
# не зависит от таксономии;
|
|
||||||
# - вынос части правил в новый файл — это новый префикс и новая нумерация:
|
|
||||||
# перенос правила между документами есть смысловое изменение, а не
|
|
||||||
# переименование;
|
|
||||||
# - тот же префикс продублирован в шапке файла (`prefix:`), conv сверяет.
|
|
||||||
#
|
|
||||||
# Префикс принадлежит файлу, тема — набору файлов: у слоёв одной темы
|
|
||||||
# префиксы разные, а имя темы одно.
|
|
||||||
#
|
|
||||||
# Пути даются от корня репозитория, а не от `conventions/`: манифест покрывает
|
|
||||||
# и обвязку тоже.
|
|
||||||
#
|
|
||||||
# Буква `X` в начале префикса зарезервирована за репозиториями-потребителями:
|
|
||||||
# набор её не занимает никогда, локальные правила берут префиксы только на
|
|
||||||
# неё (XTIM, XLOG). Согласовывать их с манифестом не нужно — столкновение
|
|
||||||
# невозможно по построению.
|
|
||||||
|
|
||||||
[prefixes.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"
|
|
||||||
|
|
||||||
[prefixes.retired]
|
|
||||||
# Пусто. Сюда попадают префиксы удалённых и разделённых файлов вместе с
|
|
||||||
# причиной и датой, чтобы их нельзя было выдать повторно.
|
|
||||||
Reference in New Issue
Block a user