Files
av 42d5b73a04 docs: перевод документации на канон av-dev
- Раскладка docs/ приведена к канону 2: заведены passport/architecture/
  database/security/review и research; docs/specs, drafts, backlog, review/
  и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи,
  6 целей, слаги на английский).
- Нарративы specs удалены как дубли openspec-спек после поимённой сверки;
  остаток заведён задачами (редактор маппинга ревью, крайние случаи
  именования), отказ от сущности title промоутнут в ADR.
- Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу
  плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon
  вместо er-schema.
2026-08-04 09:27:26 +03:00

181 lines
6.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package archrules — тесты-сканеры исходников для правил, которые не
// выражаются линтером: структура проекта и SQL миграций.
//
// Каждое правило здесь — бывшая строка прозаической конвенции: у него есть
// детерминированный оракул, поэтому ему место в конвейере сборки, а не в
// промпте ревью (процедура промоута — references/promote.md скилла
// av-dev-pipeline:review-pipeline).
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")
}