- Раскладка 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.
181 lines
6.8 KiB
Go
181 lines
6.8 KiB
Go
// 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")
|
||
}
|