конвенции: перенести механизируемое в 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
+65 -1
View File
@@ -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:
+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`.
+179
View File
@@ -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")
}
+1 -2
View File
@@ -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,
+1 -2
View File
@@ -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 {
+3 -3
View File
@@ -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)
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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-поллинг).
+1 -2
View File
@@ -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,
+1 -2
View File
@@ -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 и
+1 -1
View File
@@ -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)
+2 -6
View File
@@ -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,
+8
View File
@@ -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,
+1 -1
View File
@@ -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 —
+1 -1
View File
@@ -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)
+1 -2
View File
@@ -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 отдаёт заранее заданные ответы по очереди; считает вызовы.
+6 -6
View File
@@ -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)
+1 -2
View File
@@ -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)/"
+1 -2
View File
@@ -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) {
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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{},
+1 -2
View File
@@ -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
}
+9 -13
View File
@@ -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.