конвенции: перенести механизируемое в golangci-lint и internal/archrules

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

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-23 18:17:40 +03:00
co-authored by Claude Opus 4.8
parent 6792f7082a
commit 612344bab3
28 changed files with 325 additions and 98 deletions
+9
View File
@@ -4,6 +4,15 @@
именование) — в отличие от `docs/specs/` и `openspec/specs/`, которые
описывают, **что** система делает.
**Прозой здесь остаётся только то, что не выражается правилом.** Как только
свойство удаётся проверить машиной, оно уезжает в `.golangci.yml` или в
`internal/archrules`, а формулировка отсюда **удаляется** (остаётся пометка
«механизировано» со ссылкой на линтер). Процедура — [промоут находка →
конвенция → правило → удаление](../../.claude/skills/review-pipeline/references/promote.md).
Причина: файл на несколько сотен строк размазывает внимание по тривиальному —
и модель, и человек добросовестно проверят именование и не дойдут до формы
решения.
Конвенции **не** переносятся в OpenSpec: это не capability. Короткие
инварианты дублируются в [CLAUDE.md](../../CLAUDE.md) (агент читает его
всегда) и кратко в `openspec/config.yaml``context` (подмешивается в
+3 -2
View File
@@ -14,9 +14,10 @@
- **Конфигурация — только TOML.** Env-переменные для конфига **не
используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
Запрет `os.Getenv` механизирован (`forbidigo`).
- Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — никаких
`os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`.
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
бизнес-коде нет, только загрузчик `internal/config`.
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
## Файл и поиск
+8 -5
View File
@@ -4,11 +4,13 @@
[../specs/database.md](../specs/database.md); обоснование выбора ULID —
`openspec/changes/ulid-identity/design.md` (после архивации — в истории git).
**Механизировано:** `AUTOINCREMENT` и `DEFAULT (datetime('now'))` в новых
миграциях (`internal/archrules`), время мимо `store.Now()` (`forbidigo`).
## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. `INTEGER PRIMARY KEY
AUTOINCREMENT` в новых таблицах не используем.
**приложением** в момент создания записи.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL/логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален across таблиц — поиск по голому id находит
@@ -43,9 +45,10 @@
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT = хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение: `store.Now()` + `store.FormatTime`/
`ParseTime` (аналогично `ident.NewID` для id); `DEFAULT (datetime('now'))` на
колонках **не используется** (fail-loud при забытой вставке: `NOT NULL` без
дефолта). Зона хранения всегда UTC; таймзона отображения в UI — конфиг
`ParseTime` (аналогично `ident.NewID` для id), а не дефолт в схеме — так
забытая вставка падает громко (`NOT NULL` без дефолта). Измерение
длительности — не метка времени: для внешних вызовов его засекает
`logging.StartCall`. Зона хранения всегда UTC; таймзона отображения в UI — конфиг
`[general].timezone`.
- Миграции — goose (`internal/store/migrations`): SQL-файлы для DDL;
Go-миграции (`goose.AddMigrationContext`) — когда нужен код (генерация
+5 -6
View File
@@ -5,11 +5,13 @@
раздел «Ошибки» (коротко: лог один раз на доменной границе). Здесь — как
ошибки строятся, оборачиваются и проверяются.
**Механизировано:** сторонние пакеты ошибок — `depguard`; `err == ErrX` и
приведение типа — `errorlint`; матчинг по тексту сообщения — `internal/archrules`.
## Базовая идиома: stdlib
- Только стандартный `errors` + `fmt.Errorf`. Без `pkg/errors` (в режиме
поддержки) и `cockroachdb/errors` (стек-трейсы/Sentry избыточно для
домашнего сервиса). Контекст ошибки несёт `slog`, а не стек.
- Только стандартный `errors` + `fmt.Errorf`: контекст ошибки несёт `slog`, а не
стек — стек-трейсы и Sentry избыточны для домашнего сервиса.
- Если отладка начнёт упираться в «где именно родилась ошибка» — это сигнал
пересмотреть, а не дефолт.
@@ -36,9 +38,6 @@ jellybit — **приложение, а не библиотека**: внешн
## Проверка ошибок
- Сравнение — только `errors.Is(err, ErrX)` (не `err == ErrX`) и
`errors.As(err, &target)`. **Никогда** не матчим по тексту
(`strings.Contains(err.Error(), …)`).
- Граничные ошибки зависимостей **транслируем в доменные у источника**:
`sql.ErrNoRows` → доменный `store.ErrNotFound` в слое store, чтобы выше по
коду не торчал `database/sql`.
+12 -30
View File
@@ -8,14 +8,16 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
Краткая выжимка и инварианты — в [CLAUDE.md](../../CLAUDE.md), раздел
«Конвенции кода».
**Механизировано** (`.golangci.yml`): `slog` вместо `fmt.Print*``forbidigo`;
константный `msg` и стиль ключ-значение — `sloglint`. Ниже — только то, что
правилом не выражается.
## Принципы
- Только `log/slog`, без `fmt.Println` и прямой записи в stdout.
- Структурированный JSON (`slog.JSONHandler`), один формат для dev и prod.
- Сообщение (`msg`) — константный шаблон/категория события; данные — в
полях (атрибутах `slog`), а не в интерполяции текста.
- Каждое поле — отдельный ключ с типизированным значением. Это даёт
фильтрацию и агрегацию через `jq`/DuckDB без регулярок.
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
отдельный ключ с типизированным значением: это даёт фильтрацию и агрегацию
через `jq`/DuckDB без регулярок.
```json
{"time":"2026-06-28T11:23:45.123456Z","level":"INFO","msg":"download accepted","capability":"ingest","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","infohash":"…","media_type":"movie","title":"Дюна: Часть вторая"}
@@ -24,18 +26,8 @@ OpenSpec-спеках (`### Requirement` с `SHALL`).
## Сообщение
- `msg` — короткая константа в нижнем регистре: `download accepted`,
`recognition done`, `layout failed`. Без переменных в тексте.
- Данные кладём в атрибуты: `slog.Info("download accepted", "download_id",
id, "infohash", ih)`.
```go
// Правильно: msg — категория, данные — поля
log.Info("download accepted", "download_id", id, "media_type", "movie")
// Неправильно: данные зашиты в текст, агрегация ломается
log.Info(fmt.Sprintf("download %s accepted as movie", id))
```
`recognition done`, `layout failed`. Данные — в атрибутах:
`log.Info("download accepted", "download_id", id, "media_type", "movie")`.
- `msg` — чистая категория без неймспейс-префикса: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`
(`ingest`/`recognition`/`file-layout`/`review`), не в текст.
@@ -131,20 +123,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
## Ошибки
Go-ошибки логируем как атрибут, не как текст сообщения.
Go-ошибки логируем как атрибут, не как текст сообщения:
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ — `error`
(как по умолчанию в zap/zerolog; единый ключ важнее краткости).
```go
// Правильно: msg — категория, ошибка — поле
log.Error("layout failed", "error", err, "download_id", id)
// Неправильно: ошибка зашита в msg, агрегация по событию ломается
log.Error(err.Error())
```
Правила:
- Ошибку передаём полем `"error", err` — не склеиваем в `msg`. Ключ —
`error` (как по умолчанию в zap/zerolog; единый ключ важнее краткости).
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя —
контекст накапливается в цепочке `%w`.