// 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") }