конвенции: перенести механизируемое в 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:
+65
-1
@@ -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:
|
||||
|
||||
@@ -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` (подмешивается в
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
- **Конфигурация — только TOML.** Env-переменные для конфига **не
|
||||
используем**: окружение наследуется дочерними процессами и видно через
|
||||
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||
Запрет `os.Getenv` механизирован (`forbidigo`).
|
||||
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||
(под-структуры по секциям). Дальше по коду читаем только её — никаких
|
||||
`os.Getenv`/чтения файла в бизнес-коде, только загрузчик `internal/config`.
|
||||
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
|
||||
бизнес-коде нет, только загрузчик `internal/config`.
|
||||
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||
|
||||
## Файл и поиск
|
||||
|
||||
@@ -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,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
@@ -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`.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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-поллинг).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 и
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)/"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user