docs: документация переведена на канон av-dev-pm
- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги переименованы с транслита на английские, 85 ссылок поправлены - conventions.md разобран в docs/conventions/, local-research.md — в docs/research/, review-journal.md — в docs/review.md с разделом настройки конвейера; заведены security.md, adr/ и .pm.json - шаг docs.py check добавлен в task gate; поведение в architecture.md помечено девятью маркерами долга, database.md получил настройки с числовым значением
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Конвенции кода
|
||||
|
||||
Как мы пишем код — в отличие от `openspec/specs/`, который описывает, что
|
||||
система делает, и [architecture.md](../architecture.md), который описывает, как
|
||||
она сложена.
|
||||
|
||||
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
|
||||
правилом линтера, отсюда удаляется и переезжает в перечень «Механизировано».
|
||||
|
||||
Язык документации и кода — в [CLAUDE.md](../../CLAUDE.md): это правило шире
|
||||
кода, оно касается и коммитов, и документов.
|
||||
|
||||
## Записи
|
||||
|
||||
- [errors.md](errors.md) — ошибки: обёртка, sentinel против типа, трансляция на
|
||||
границе, что глушим.
|
||||
- [logging.md](logging.md) — логи: уровень по адресату, единственный логирующий
|
||||
чекпоинт, что не попадает в лог никогда.
|
||||
- [config.md](config.md) — конфигурация: TOML, валидация на старте,
|
||||
самодокументируемый образец.
|
||||
- [storage.md](storage.md) — база и идентификаторы: ULID и естественные ключи,
|
||||
время в UTC, правило выбора между версиями, отпечаток витрины, миграции.
|
||||
- [testing.md](testing.md) — тесты: реальные пакеты в `testdata`,
|
||||
идемпотентность, перебор версий, замер на живом архиве.
|
||||
|
||||
## Механизировано
|
||||
|
||||
Проверяет `task lint` по [.golangci.yml](../../.golangci.yml). Пересказывать эти
|
||||
правила прозой не нужно — линтер скажет точнее и всегда актуальнее.
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| `msg` лога — константная категория, данные атрибутами | `sloglint` |
|
||||
| Без `fmt.Print*` — логируем через `slog` | `forbidigo` |
|
||||
| Конфигурация только из TOML, без `os.Getenv` | `forbidigo` |
|
||||
| Время генерирует `store.Now()`, не `time.Now()` | `forbidigo` |
|
||||
| Сравнение ошибок через `errors.Is`/`As`, не `==` | `errorlint` |
|
||||
| Ошибки только stdlib `errors` + `fmt.Errorf` | `depguard` |
|
||||
| Стек-трейсы избыточны — контекст несёт `slog` | `depguard` |
|
||||
|
||||
Плюс шаги [`task gate`](../../Taskfile.yml): сборка, `go vet`, `gofmt`, тесты,
|
||||
гонки, покрытие изменённых строк, миграции, образцы конфига, секреты в индексе,
|
||||
данные о здоровье в индексе.
|
||||
|
||||
Непойманное место механизации означает, что проход по конвенциям будет
|
||||
добросовестно проверять уже проверенное.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Конфигурация
|
||||
|
||||
- Только **TOML**, никаких env-переменных: окружение наследуется дочерними
|
||||
процессами и видно через `/proc/<pid>/environ` — для токенов это слабее
|
||||
файла под `0600`.
|
||||
- Грузим один раз при старте в типизированную `Config`; дальше по коду читаем
|
||||
только её. Конфиг неизменяем — смена параметров означает рестарт.
|
||||
- Имя по умолчанию — `config.toml` в рабочей директории, переопределяется
|
||||
`--config=path`.
|
||||
- `config.example.toml` коммитим как единый самодокументируемый справочник:
|
||||
**каждое поле с комментарием**, из которого ясно зачем оно, каков диапазон
|
||||
допустимых значений и в каких единицах. Секретные поля — пустые.
|
||||
- Реальный `config.toml` не коммитится; секреты рендерит деплой.
|
||||
- **Валидация на старте, до приёма трафика.** Невалидный конфиг — `ERROR` и
|
||||
выход с ненулевым кодом. Не стартуем «наполовину».
|
||||
@@ -0,0 +1,24 @@
|
||||
# Ошибки
|
||||
|
||||
- Только стандартный `errors` + `fmt.Errorf`. Сторонних пакетов ошибок нет:
|
||||
контекст несёт `slog`, стек-трейсы для домашнего сервиса избыточны.
|
||||
- Контекст добавляем обёрткой `%w` — это дефолт, чтобы `errors.Is`/`As`
|
||||
работали сквозь слои. `%v` — только когда причину сознательно не
|
||||
раскрываем.
|
||||
- Стиль сообщения: со строчной, без точки, без «failed to». Контекст —
|
||||
операция или субъект (`"open archive: %w"`), каждый слой добавляет **свой**
|
||||
смысл, не повторяя нижний.
|
||||
- Граничные ошибки транслируем в доменные у источника: `sql.ErrNoRows` →
|
||||
`store.ErrNotFound` внутри `store`, чтобы выше не торчал `database/sql`.
|
||||
- **Sentinel** (`var ErrNotFound = errors.New(...)`) — для условий, на которые
|
||||
ветвится код. **Типизированная ошибка** — когда вызывающему нужны данные
|
||||
ошибки. Не плодим типы там, где хватает sentinel.
|
||||
- Наружу (HTTP) отдаём человекочитаемое сообщение по доменной ошибке, не
|
||||
сырой `err.Error()`. Маппинг доменная ошибка → статус живёт в одной точке
|
||||
в `httpapi`; новая штатная ветвь отказа заводится sentinel'ом и
|
||||
добавляется туда, иначе `default` отдаст 500 на нормальный конфликт.
|
||||
- Собрать независимые ошибки (валидация конфига — все проблемы разом) —
|
||||
`errors.Join`.
|
||||
- `panic` — только невосстановимое: нарушенный инвариант, сбой инициализации.
|
||||
`recover` — на верхней границе HTTP-обработчика.
|
||||
- Глушить ошибку без лога — только с однострочным комментарием «почему».
|
||||
@@ -0,0 +1,38 @@
|
||||
# Логи
|
||||
|
||||
Структурированный JSON (`log/slog`) в stdout, один формат для dev и prod.
|
||||
Сбор и ротацию делает окружение.
|
||||
|
||||
- `msg` — короткая константа в нижнем регистре, категория события
|
||||
(`delivery accepted`, `parse failed`). Данные — атрибутами, не в тексте.
|
||||
Подсистему выносим в поле `capability` (`ingest`/`parse`/`query`), не в
|
||||
префикс сообщения.
|
||||
- **Уровень — это адресат, а не громкость поломки:**
|
||||
|
||||
| Уровень | Кому | Примеры |
|
||||
|---|---|---|
|
||||
| `DEBUG` | разработчику при отладке | `/healthz`, тела запросов, шаги разбора |
|
||||
| `INFO` | владельцу, аудит постфактум | принята доставка, разбор завершён, старт |
|
||||
| `WARN` | владельцу, «может стать проблемой» | точка не разобрана, незнакомая форма метрики |
|
||||
| `ERROR` | владельцу, в разбор | не записался архив, сбой БД |
|
||||
|
||||
- Невалидный ввод от отправителя — `DEBUG`, а не `ERROR`: это норма, разбирать
|
||||
нечего. `WARN` ≠ «ничего страшного», `WARN` = «может стать проблемой».
|
||||
- Событийное → `INFO`, рутинно-частое (healthcheck, поллинг) → `DEBUG`.
|
||||
- **Либо лог, либо возврат, не оба.** Промежуточные слои только оборачивают и
|
||||
возвращают. Ошибка логируется **один раз**, на границе доменного слоя,
|
||||
которая определяет исход операции (`ingest`) — не в транспорте. Транспорт
|
||||
переводит ошибку в ответ и не логирует повторно.
|
||||
- Ошибка — атрибутом: `log.Error("parse failed", "error", err, "delivery_id", id)`.
|
||||
- Время в логах — UTC, RFC 3339 с долями секунды.
|
||||
- Корреляция — по `delivery_id` (ULID), отдельный `trace_id` не заводим.
|
||||
- **Секреты в логи не попадают**: токены приёма и чтения, `Authorization`.
|
||||
При сомнении логируем факт наличия, не значение.
|
||||
- Данные о здоровье — чувствительные. Тела запросов пишем только на `DEBUG`
|
||||
и с обрезкой по длине.
|
||||
- **Текст ошибки разбора не содержит значений из входа** — только род токена
|
||||
(словарём JSON, не именем типа языка) и смещение. Инвариант выше обходится
|
||||
одним `fmt.Errorf("%v", tok)`: тело в 8 МиБ дало текст ошибки в 8 МиБ, и он
|
||||
уехал атрибутом `error` на уровень `WARN`. Предел держит само сообщение, а не
|
||||
обрезка на стороне логирующего: обрезка живёт в другом месте и о новой ошибке
|
||||
разбора не узнает.
|
||||
@@ -0,0 +1,49 @@
|
||||
# База данных и идентификаторы
|
||||
|
||||
Схема как таковая — в [database.md](../database.md); здесь только правила, по
|
||||
которым она пишется.
|
||||
|
||||
- Первичные ключи сущностей — **TEXT ULID**, генерируется приложением
|
||||
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
|
||||
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
|
||||
невалидный id — 404 без похода в БД.
|
||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout` —
|
||||
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
|
||||
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
|
||||
в двух секциях затёр бы одну запись другой молча).
|
||||
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
|
||||
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
|
||||
единица, которой нет в счётчиках, делает расхождение безадресным: человек
|
||||
видит «не совпало» при неизменившемся числе объектов и принимает по этому
|
||||
необратимое решение о подмене базы.
|
||||
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
||||
множества версий**, либо явно **функцией порядка журнала** — третьего
|
||||
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
|
||||
порядок свёртки порядку журнала не равен, и живая витрина расходится с
|
||||
пересборкой молча.
|
||||
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||
- **Колонка, по которой принимается необратимое решение, отличает ноль от «не
|
||||
измерялось».** Миграция, добавляющая такую колонку, не подставляет ноль
|
||||
историческим строкам: ноль означает «проверено, пусто», а не «не знаем», и
|
||||
подстановка выдаёт неизмеренное за измеренное — с видом измерения. Пример:
|
||||
`delivery.skipped_entities`, по которому ретеншен решает, можно ли удалить
|
||||
тело.
|
||||
- **Метка изменения строки меняется только при изменении содержимого.** Апдейт,
|
||||
трогающий одни метаданные (провенанс, ссылки), `updated_at` не двигает — иначе
|
||||
она становится меткой касания, и запрос «что изменилось с момента X» получает
|
||||
столько ложных изменений, сколько раз источник переприслал то же самое (у
|
||||
тренировки — двадцать шесть).
|
||||
- **Новая производная от разбора колонка в момент появления вносится в перечень
|
||||
того, что пересборка не переносит.** Перечень — единственное место, где это
|
||||
сказано, и следующий автор решает по нему; поле, не внесённое туда, однажды
|
||||
перенесут «для полноты учёта», и витрина снова станет функцией предыдущего
|
||||
прогона.
|
||||
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
||||
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
||||
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
||||
падать громко.
|
||||
- Enum-поля — обычный `TEXT` без `CHECK`, допустимые значения держит код.
|
||||
- Миграции — goose (`internal/store/migrations`), SQL для DDL. При изменении
|
||||
структуры обновляем схему в [database.md](../database.md) тем же изменением —
|
||||
это проверяет `task gate`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Тесты
|
||||
|
||||
- Тесты на разбор формата HAE держим на **реальных пакетах**, сложенных в
|
||||
`testdata` (с вычищенными токенами). Документация формата ненадёжна —
|
||||
источником истины служат живые данные.
|
||||
- Проверяем идемпотентность: повторный разбор того же пакета не меняет
|
||||
витрину.
|
||||
- **Где код выбирает между двумя версиями одних данных, тест обязан прогнать
|
||||
обе стороны и хотя бы одну перестановку трёх.** Пример на паре доказывает
|
||||
коммутативность и молчит про ассоциативность, а сломаться правило может
|
||||
именно на ней: полнота — частичный порядок, тай-брейк — тотальный, и их
|
||||
попарная свёртка дала нетранзитивное отношение победы, из-за которого одна
|
||||
и та же доставка меняла витрину при каждой пересборке. Ревью дизайна этого
|
||||
не увидело, ревью кода увидело только перебором троек. Правилом линтера не
|
||||
выражается — отсюда проза.
|
||||
- **В тот же перебор обязана входить версия с содержимым, равным одной из уже
|
||||
присланных, и пара «равная каноническая форма, разные байты».** Три версии с
|
||||
разными хешами ветку «содержание равно» не посещают ни разу — а именно на ней
|
||||
устаревал провенанс, и живая витрина расходилась с пересборкой молча. Пара с
|
||||
равной формой ловит другое: неединственный минимум, при котором победителем
|
||||
оказывается просто первый в срезе, то есть порядок элементов на проводе.
|
||||
- **Изменение правила разбора или слияния сопровождается замером на живом
|
||||
архиве, и ответ «пересворачивать нечего» произносится с числом.** Утверждение
|
||||
без числа не отличается от предположения, а цена ошибки здесь — необратимое
|
||||
решение о судьбе тел.
|
||||
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||
прогонов от хода часов, а не от утечки. Разбираем запись, выбрасываем `time`
|
||||
и ищем в остатке. Правило общее — таких тестов будет больше (токены, тела
|
||||
запросов, координаты объектов).
|
||||
Reference in New Issue
Block a user