конвенции: перенести механизируемое в 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:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user