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