конвенции: перенести механизируемое в 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
+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
}