diff --git a/.golangci.yml b/.golangci.yml index d532ca4..ea397a0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,11 +1,61 @@ # Конфиг golangci-lint (схема v2; устанавливается через `task setup`). +# # Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign, -# staticcheck, unused; дополнительно включаем misspell. +# staticcheck, unused. Сверх него включены линтеры, которые механизируют +# конвенции из docs/conventions/*: то, что проверяет правило, не должно +# оставаться прозой в конвенциях и в промптах ревью (см. +# .claude/skills/review-pipeline/references/promote.md). version: "2" linters: enable: - misspell + # docs/conventions/logging.md: msg — константная категория, данные — в + # полях, единый стиль ключ-значение. + - sloglint + # docs/conventions/logging.md (без fmt.Println), config.md (конфиг только + # из TOML, env не используем), database.md (время — только store.Now()). + - forbidigo + # docs/conventions/errors.md: сравнение ошибок через errors.Is/As, а не + # `err == ErrX` и не приведением типа. + - errorlint + # docs/conventions/errors.md: ошибки — только stdlib. + - depguard + + settings: + sloglint: + no-mixed-args: true # не мешать пары «ключ-значение» с slog.Attr + kv-only: true # принятый в проекте стиль вызова + static-msg: true # msg — константа, без fmt.Sprintf и интерполяции + # key-naming-case НЕ включаем: словарь полей намеренно смешанный — + # доменные поля snake_case, системные домены с точкой (`http.method`, + # `ext.service`, адаптация OpenTelemetry). См. logging.md, «Поля». + + forbidigo: + forbid: + - pattern: ^fmt\.Print.*$ + msg: логируем через slog, в stdout напрямую не пишем (docs/conventions/logging.md) + - pattern: ^os\.Getenv$ + msg: конфигурация только из TOML, env для конфига не используем (docs/conventions/config.md) + - pattern: ^time\.Now$ + msg: время генерирует store.Now() (UTC, единая точка) — docs/conventions/database.md + + errorlint: + # Обёртка вида fmt.Errorf("%w: %v", ErrSentinel, err) осознанна: sentinel + # раскрываем для errors.Is, причину — намеренно нет (errors.md, «%w vs %v»). + errorf: false + asserts: true + comparison: true + + depguard: + rules: + main: + deny: + - pkg: github.com/pkg/errors + desc: ошибки — только stdlib errors + fmt.Errorf (docs/conventions/errors.md) + - pkg: github.com/cockroachdb/errors + desc: стек-трейсы избыточны, контекст несёт slog (docs/conventions/errors.md) + exclusions: generated: lax presets: @@ -17,6 +67,20 @@ linters: - third_party$ - builtin$ - examples$ + rules: + # CLI — другая поверхность: печатает результат в stdout и меряет + # длительность своей работы, это не логирование и не время в БД. + - path: ^cmd/ + linters: [forbidigo] + # Интеграционные тесты берут креды внешних сервисов из окружения — + # это не конфигурация приложения. + - path: _test\.go$ + text: os.Getenv + linters: [forbidigo] + # Единые точки генерации id и времени — им time.Now по определению можно. + - path: ^internal/(ident|store)/ + text: time.Now + linters: [forbidigo] formatters: exclusions: diff --git a/docs/conventions/README.md b/docs/conventions/README.md index 26a5819..1cba766 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -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` (подмешивается в diff --git a/docs/conventions/config.md b/docs/conventions/config.md index 5c60fce..01f2a1c 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -14,9 +14,10 @@ - **Конфигурация — только TOML.** Env-переменные для конфига **не используем**: окружение наследуется дочерними процессами и видно через `/proc//environ` — для секретов это слабее файла под `0600`. + Запрет `os.Getenv` механизирован (`forbidigo`). - Грузим **один раз при старте** в одну типизированную структуру `Config` - (под-структуры по секциям). Дальше по коду читаем только её — никаких - `os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`. + (под-структуры по секциям). Дальше по коду читаем только её — чтения файла в + бизнес-коде нет, только загрузчик `internal/config`. - Конфиг **неизменяем** после старта; смена параметров — рестарт процесса. ## Файл и поиск diff --git a/docs/conventions/database.md b/docs/conventions/database.md index d12841e..80ed953 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -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`) — когда нужен код (генерация diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index 042590d..5f08758 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -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`. diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index 5d93a19..f14e8bb 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -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`. diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go new file mode 100644 index 0000000..95d43d6 --- /dev/null +++ b/internal/archrules/arch_test.go @@ -0,0 +1,179 @@ +// Package archrules — тесты-сканеры исходников для правил, которые не +// выражаются линтером: структура проекта и SQL миграций. +// +// Каждое правило здесь — бывшая строка прозаической конвенции: у него есть +// детерминированный оракул, поэтому ему место в конвейере сборки, а не в +// промпте ревью (см. .claude/skills/review-pipeline/references/promote.md). +package archrules + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +const modulePath = "git.vakhrushev.me/av/jellybit" + +// repoRoot — корень репозитория относительно каталога пакета. +const repoRoot = "../.." + +// Транспорты — тонкие обёртки над ядром: не знают друг о друге и никем из ядра +// не импортируются (CLAUDE.md, «Единое ядро, тонкие транспорты»). +var transports = map[string]bool{ + "internal/httpapi": true, + "internal/tgbot": true, +} + +func TestТранспортыНеЗависятДругОтДруга(t *testing.T) { + for pkg, imports := range internalImports(t) { + if !transports[pkg] { + continue + } + for _, imp := range imports { + if transports[imp] && imp != pkg { + t.Errorf("%s импортирует транспорт %s: транспорты не знают друг о друге, общая логика живёт в ядре", pkg, imp) + } + } + } +} + +func TestЯдроНеЗависитОтТранспортов(t *testing.T) { + for pkg, imports := range internalImports(t) { + if transports[pkg] || pkg == "cmd/jellybit" { + continue + } + for _, imp := range imports { + if transports[imp] { + t.Errorf("%s импортирует транспорт %s: зависимость направлена не туда, ядро не знает о доставке", pkg, imp) + } + } + } +} + +// lastLegacyMigration — последняя миграция, написанная до того, как конвенция +// сложилась: 0001 заводила AUTOINCREMENT и DEFAULT datetime('now'), 0006 и 0008 +// как раз уводили схему на ULID и RFC 3339 и потому упоминают старую форму. +// Миграции неизменяемы, переписывать их нельзя — правило действует на новые. +const lastLegacyMigration = 8 + +// docs/conventions/database.md: PK — TEXT ULID через internal/ident, время +// генерирует приложение (store.Now), а не SQLite. +func TestМиграцииБезAutoincrementИСерверногоВремени(t *testing.T) { + forbidden := []struct { + re *regexp.Regexp + why string + }{ + {regexp.MustCompile(`(?i)autoincrement`), "PK — TEXT ULID через internal/ident, без AUTOINCREMENT"}, + {regexp.MustCompile(`(?i)default\s*\(?\s*(datetime\s*\(\s*'now'|current_timestamp)`), "время генерирует приложение через store.Now(), а не DEFAULT в схеме (fail-loud при забытой вставке)"}, + } + dir := filepath.Join(repoRoot, "internal/store/migrations") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("читаю каталог миграций: %v", err) + } + for _, e := range entries { + if e.IsDir() || migrationNumber(t, e.Name()) <= lastLegacyMigration { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatalf("читаю %s: %v", e.Name(), err) + } + for _, f := range forbidden { + if loc := f.re.FindIndex(body); loc != nil { + t.Errorf("%s: строка %d — %s", e.Name(), lineOf(body, loc[0]), f.why) + } + } + } +} + +// docs/conventions/errors.md: сравнение ошибок — errors.Is/errors.As, никогда +// по тексту. errorlint ловит `err == ErrX` и приведение типа, но не матчинг +// подстрокой — его ловим здесь. +func TestОшибкиНеМатчатсяПоТексту(t *testing.T) { + re := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`) + for _, path := range goFiles(t) { + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("читаю %s: %v", path, err) + } + if loc := re.FindIndex(body); loc != nil { + rel, _ := filepath.Rel(repoRoot, path) + t.Errorf("%s:%d — ошибку матчим через errors.Is/errors.As, а не по тексту сообщения", rel, lineOf(body, loc[0])) + } + } +} + +// internalImports возвращает карту «пакет репозитория → его внутренние импорты» +// (пути относительно корня модуля). +func internalImports(t *testing.T) map[string][]string { + t.Helper() + out := map[string][]string{} + fset := token.NewFileSet() + for _, path := range goFiles(t) { + f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("разбираю %s: %v", path, err) + } + rel, err := filepath.Rel(repoRoot, filepath.Dir(path)) + if err != nil { + t.Fatalf("отношу путь %s: %v", path, err) + } + for _, imp := range f.Imports { + p := strings.Trim(imp.Path.Value, `"`) + if after, ok := strings.CutPrefix(p, modulePath+"/"); ok { + out[rel] = append(out[rel], after) + } + } + } + return out +} + +// goFiles — все нетестовые .go файлы репозитория (без tmp и вендорных каталогов). +func goFiles(t *testing.T) []string { + t.Helper() + var files []string + err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case "tmp", "vendor", ".git", "node_modules": + return filepath.SkipDir + } + return nil + } + if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("обхожу репозиторий: %v", err) + } + return files +} + +// migrationNumber достаёт числовой префикс имени миграции (0009_… → 9). +func migrationNumber(t *testing.T, name string) int { + t.Helper() + prefix, _, ok := strings.Cut(name, "_") + if !ok { + t.Fatalf("имя миграции без числового префикса: %s", name) + } + n, err := strconv.Atoi(prefix) + if err != nil { + t.Fatalf("нечисловой префикс миграции %s: %v", name, err) + } + return n +} + +func lineOf(body []byte, offset int) int { + return 1 + strings.Count(string(body[:offset]), "\n") +} diff --git a/internal/httpapi/action_swap_test.go b/internal/httpapi/action_swap_test.go index 47674c9..b4c8c26 100644 --- a/internal/httpapi/action_swap_test.go +++ b/internal/httpapi/action_swap_test.go @@ -2,7 +2,6 @@ package httpapi import ( "context" - "io" "log/slog" "net/http" "net/http/httptest" @@ -50,7 +49,7 @@ func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error { func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler { t.Helper() h, err := NewRouter(Deps{ - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Logger: slog.New(slog.DiscardHandler), Reader: r, Reviewer: rv, Commander: cmd, diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index e60daf9..1d2e8dd 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" "strconv" - "time" "git.vakhrushev.me/av/jellybit/internal/naming" "git.vakhrushev.me/av/jellybit/internal/recognize" @@ -134,7 +133,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet // как в порядке и карточках списка); неразбираемое время просто опускаем. if t, ok := addedTime(d); ok { view.Added = fmtDate(t, s.deps.Loc) - view.AddedAgo = humanizeAge(t, time.Now()) + view.AddedAgo = humanizeAge(t, store.Now()) } if rd.Recognition != nil { diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 7512bd5..078603b 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -303,7 +303,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает } - now := time.Now() + now := store.Now() for _, d := range downloads { view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID])) } @@ -500,7 +500,7 @@ func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id s s.deps.Logger.Error("layout sizes", "download_id", id, "error", err) sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает } - v := s.buildCardView(*d, time.Now(), sizes[id]) + v := s.buildCardView(*d, store.Now(), sizes[id]) if actionErr != nil { v.ActionError = userErr(r, actionErr, id) } @@ -841,7 +841,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) - start := time.Now() + start := time.Now() //nolint:forbidigo // измеряем длительность запроса, а не метку времени в БД next.ServeHTTP(ww, r) diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 4c5f0dd..872fd9b 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -98,7 +98,7 @@ func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[st func newServer(t *testing.T, d httpapi.Deps) *httptest.Server { t.Helper() if d.Logger == nil { - d.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + d.Logger = slog.New(slog.DiscardHandler) } h, err := httpapi.NewRouter(d) if err != nil { diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index 2de0a93..7b9cab5 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -113,7 +113,7 @@ func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) { } // layoutSize 0: у catched раскладки нет; в downloading размер берётся из // живого снимка внутри buildCardView. - s.render(w, "card", s.buildCardView(*d, time.Now(), 0)) + s.render(w, "card", s.buildCardView(*d, store.Now(), 0)) } // handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг). diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index 8fdac32..de05609 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -2,7 +2,6 @@ package httpapi import ( "context" - "io" "log/slog" "net/http" "net/http/httptest" @@ -77,7 +76,7 @@ func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler { func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler { t.Helper() h, err := NewRouter(Deps{ - Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Logger: slog.New(slog.DiscardHandler), Reader: r, Reviewer: rv, Live: lv, diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index f0bdf58..f8b561e 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -3,7 +3,6 @@ package ingest import ( "context" "errors" - "io" "log/slog" "strings" "testing" @@ -79,7 +78,7 @@ func (r *raceStore) UpgradeCatchedMagnetToTorrent(_ context.Context, _ string, _ } func newService(st Store) *Service { - return New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + return New(st, slog.New(slog.DiscardHandler)) } // Быстрый приём: сохраняем загрузку в catched и сразу отвечаем; qBittorrent и diff --git a/internal/jellyfin/jellyfin.go b/internal/jellyfin/jellyfin.go index 2dd951e..a17dd57 100644 --- a/internal/jellyfin/jellyfin.go +++ b/internal/jellyfin/jellyfin.go @@ -81,7 +81,7 @@ func (c *Client) RefreshLibraries(ctx context.Context) error { req.Header.Set("X-Emby-Token", c.apiKey) log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceJellyfin, Operation: "library/refresh", Start: time.Now()} + call := logging.StartCall(logging.ServiceJellyfin, "library/refresh") resp, err := c.hc.Do(req) if err != nil { call.Failure(log, err) diff --git a/internal/llm/openai.go b/internal/llm/openai.go index dd2bb49..7ed82f1 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -128,12 +128,8 @@ func (c *openAICompat) Complete(ctx context.Context, req Request) (Response, err } } - call := logging.ExtCall{ - Service: logging.ServiceLLM, - Operation: "chat.completions", - Start: time.Now(), - Attempt: attempt, - } + call := logging.StartCall(logging.ServiceLLM, "chat.completions") + call.Attempt = attempt resp, retryable, err := c.do(ctx, body) if err == nil { call.Success(log, "model", resp.Model, diff --git a/internal/logging/ext.go b/internal/logging/ext.go index c20b2cb..2c97d9b 100644 --- a/internal/logging/ext.go +++ b/internal/logging/ext.go @@ -26,6 +26,14 @@ type ExtCall struct { Attempt int // номер попытки; >0 — пишем поле retry (поле и метод Retry конфликтовали бы) } +// StartCall заводит запись о начинающемся вызове внешнего сервиса, засекая +// время. Единая точка отсчёта длительности: клиентам не нужен собственный +// time.Now, а конвенция «время генерирует store.Now()» остаётся без исключений +// (здесь это не метка времени, а измерение — см. docs/conventions/logging.md). +func StartCall(service, operation string) ExtCall { + return ExtCall{Service: service, Operation: operation, Start: time.Now()} //nolint:forbidigo // единственная точка отсчёта длительности внешних вызовов +} + func (c ExtCall) attrs(extra ...any) []any { a := make([]any, 0, 10+len(extra)) a = append(a, diff --git a/internal/metadata/http.go b/internal/metadata/http.go index d94802c..56730b3 100644 --- a/internal/metadata/http.go +++ b/internal/metadata/http.go @@ -76,7 +76,7 @@ func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, o // при отсутствии — переданный fallback. func doJSON(ctx context.Context, hc *http.Client, log *slog.Logger, service, operation string, req *http.Request, out any) error { log = logctx.FromOr(ctx, log) - call := logging.ExtCall{Service: service, Operation: operation, Start: time.Now()} + call := logging.StartCall(service, operation) resp, err := hc.Do(req) if err != nil { // Транспортный сбой несёт *url.Error с полным URL, а у TMDB api_key — diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go index af6ca4b..6d2342f 100644 --- a/internal/metadata/tvdb.go +++ b/internal/metadata/tvdb.go @@ -126,7 +126,7 @@ func (t *TVDB) rawGet(ctx context.Context, operation, path, token string) (int, req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") log := logctx.FromOr(ctx, t.log) - call := logging.ExtCall{Service: logging.ServiceTVDB, Operation: operation, Start: time.Now()} + call := logging.StartCall(logging.ServiceTVDB, operation) resp, err := t.hc.Do(req) if err != nil { call.Failure(log, err) diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index c8f577e..4cfa247 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -3,7 +3,6 @@ package naming import ( "context" "errors" - "io" "log/slog" "testing" @@ -11,7 +10,7 @@ import ( ) func testLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) + return slog.New(slog.DiscardHandler) } // fakeProvider отдаёт заранее заданные ответы по очереди; считает вызовы. diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index fa8556f..f13f991 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -141,7 +141,7 @@ func (c *Client) login(ctx context.Context) error { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Referer", c.base.String()) // qBit проверяет Referer/Host log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "auth/login", Start: time.Now()} + call := logging.StartCall(logging.ServiceQBittorrent, "auth/login") resp, err := c.hc.Do(req) if err != nil { call.Failure(log, err) @@ -221,7 +221,7 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error { payload := buf.Bytes() log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/add", Start: time.Now()} + call := logging.StartCall(logging.ServiceQBittorrent, "torrents/add") resp, err := c.do(ctx, func() (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/api/v2/torrents/add"), bytes.NewReader(payload)) @@ -278,7 +278,7 @@ func (c *Client) Delete(ctx context.Context, hashes []string, deleteFiles bool) body := form.Encode() log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/delete", Start: time.Now()} + call := logging.StartCall(logging.ServiceQBittorrent, "torrents/delete") resp, err := c.do(ctx, func() (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/api/v2/torrents/delete"), strings.NewReader(body)) @@ -319,7 +319,7 @@ func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error { body := form.Encode() log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/rename", Start: time.Now()} + call := logging.StartCall(logging.ServiceQBittorrent, "torrents/rename") resp, err := c.do(ctx, func() (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint("/api/v2/torrents/rename"), strings.NewReader(body)) @@ -350,7 +350,7 @@ func (c *Client) RenameTorrent(ctx context.Context, hash, name string) error { // Torrents возвращает задачи указанной категории (пустая — все). func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, error) { log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/info", Start: time.Now()} + call := logging.StartCall(logging.ServiceQBittorrent, "torrents/info") resp, err := c.do(ctx, func() (*http.Request, error) { u := c.endpoint("/api/v2/torrents/info") if category != "" { @@ -386,7 +386,7 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro // распознаванию как один из сигналов; абсолютный путь — join(save_path, Name). func (c *Client) Files(ctx context.Context, hash string) ([]File, error) { log := logctx.FromOr(ctx, c.log) - call := logging.ExtCall{Service: logging.ServiceQBittorrent, Operation: "torrents/files", Start: time.Now()} + call := logging.StartCall(logging.ServiceQBittorrent, "torrents/files") resp, err := c.do(ctx, func() (*http.Request, error) { u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash)) return http.NewRequestWithContext(ctx, http.MethodGet, u, nil) diff --git a/internal/recognize/integration_test.go b/internal/recognize/integration_test.go index d610bf0..63d7d90 100644 --- a/internal/recognize/integration_test.go +++ b/internal/recognize/integration_test.go @@ -2,7 +2,6 @@ package recognize_test import ( "context" - "io" "log/slog" "os" "strconv" @@ -43,7 +42,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) { t.Fatalf("llm.New: %v", err) } - log := slog.New(slog.NewTextHandler(io.Discard, nil)) + log := slog.New(slog.DiscardHandler) r := recognize.New(provider, nil, recognize.Config{MaxRetries: 2}, log) const dir = "Аватар Легенда об Аанге.Книга 2.Земля(Avatar The Last Airbender The book 2.Earth)/" diff --git a/internal/recognize/recognize_test.go b/internal/recognize/recognize_test.go index 4376099..934b883 100644 --- a/internal/recognize/recognize_test.go +++ b/internal/recognize/recognize_test.go @@ -3,7 +3,6 @@ package recognize import ( "context" "errors" - "io" "log/slog" "strings" "testing" @@ -37,7 +36,7 @@ func (f *fakeLLM) Complete(_ context.Context, req llm.Request) (llm.Response, er } func testLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) + return slog.New(slog.DiscardHandler) } func TestRecognize_Movie(t *testing.T) { diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index 24d9c0d..b884560 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -3,7 +3,6 @@ package tgbot import ( "context" "database/sql" - "io" "log/slog" "strings" "testing" @@ -147,7 +146,7 @@ func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, * ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}} rev := &fakeReviewer{data: reviewData(store.StateReview)} b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"}, - slog.New(slog.NewTextHandler(io.Discard, nil))) + slog.New(slog.DiscardHandler)) return b, api, ing, rev } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index ba63ede..415887f 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "log/slog" "os" "path/filepath" @@ -753,7 +752,7 @@ func (f *fakeRecognizer) Director(_ context.Context, _ recognize.MediaType, _, _ func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker { w := New(st, qb, rec, lay, Config{Category: "jellybit"}, - slog.New(slog.NewTextHandler(io.Discard, nil))) + slog.New(slog.DiscardHandler)) n := 0 w.newID = func() string { n++; return "batch-" + itoa(n) } return w diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 9537407..759318a 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -281,7 +281,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log layouter: lay, cfg: cfg, log: log, - now: time.Now, + now: store.Now, newID: defaultBatchID, failNotified: map[string]time.Time{}, live: map[string]Live{}, diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index b6e63b0..6984412 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "log/slog" "testing" "time" @@ -360,7 +359,7 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker { SavePath: "/srv/media/downloads", MagnetTimeout: 30 * time.Minute, StuckAfter: time.Hour, - }, slog.New(slog.NewTextHandler(io.Discard, nil))) + }, slog.New(slog.DiscardHandler)) w.now = func() time.Time { return time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) } return w } diff --git a/openspec/config.yaml b/openspec/config.yaml index 4376ea0..73d6c73 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -29,19 +29,15 @@ context: | - Тривиальная задача — достаточно одного прохода (код). Конвенции кода (соблюдать при apply): - - Логирование — только log/slog (структурированный JSON), без fmt.Println. - Логируем все вызовы внешних сервисов; healthcheck-эндпоинты — на DEBUG. - Детали: уровни, обязательные поля — docs/conventions/logging.md. - - Безопасность: никаких секретов в полях логов (пароли qBittorrent, - API-ключи LLM/метабаз, auth-заголовки). - - Конфигурация — только TOML; секреты рендерит деплой (Ansible+Vault) в - файл (config.toml не коммитится, 0600), env для конфига не используем; - валидация на старте. Детали: docs/conventions/config.md. - - Ошибки — stdlib, обёртка с контекстом (fmt.Errorf("...: %w", err)), - проверка errors.Is/errors.As, трансляция доменной ошибки в ответ на - внешней границе (наружу не отдаём текст внутренней ошибки). Детали: - docs/conventions/errors.md. - - Время — всегда с явным TZ (сервер в Europe/Moscow; логи — в UTC). + - Механизируемое проверяет конвейер сборки (.golangci.yml + internal/archrules), + пересказывать его здесь не нужно: `task lint` и `task test` скажут точнее. + - Прозой остаётся то, что правилом не выражается, и это читаем в источнике: + docs/conventions/{logging,errors,config,database,web-ui}.md — уровень лога + по адресату, единственный логирующий чокпоинт на доменной границе, + трансляция доменной ошибки на внешней границе, самодокументируемый + config.example.toml, htmx-партиалы. + - Безопасность: никаких секретов в полях логов и в диагностике состояния + (пароли qBittorrent, API-ключи LLM/метабаз, auth-заголовки). # Project context (optional) # This is shown to AI when creating artifacts.