Files
jellybit/internal/archrules/arch_test.go
T
av fc9a3b4066 распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
2026-09-02 08:54:58 +03:00

201 lines
8.0 KiB
Go
Raw 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` и приведение типа, но не матчинг
// подстрокой — его ловим здесь.
//
// Прямой формы мало: `msg := strings.ToLower(err.Error())` и следом
// `strings.Contains(msg, …)` — то же сравнение по тексту, просто через
// промежуточную переменную (так оно и просочилось в recognize). Поэтому вторым
// проходом собираем имена, которым присвоен `.Error()`, и ищем их в тех же
// сравнениях.
func TestОшибкиНеМатчатсяПоТексту(t *testing.T) {
direct := regexp.MustCompile(`(strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\([^)]*\.Error\(\)|\.Error\(\)\s*==)`)
// Присваивание текста ошибки переменной, в т.ч. завёрнутое в вызовы
// (strings.ToLower, strings.TrimSpace и подобные).
assigned := regexp.MustCompile(`(?m)^\s*(\w+)\s*:?=\s*(?:[\w.]+\()*\s*[\w.]+\.Error\(\)`)
const why = "ошибку матчим через errors.Is/errors.As, а не по тексту сообщения"
for _, path := range goFiles(t) {
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("читаю %s: %v", path, err)
}
rel, _ := filepath.Rel(repoRoot, path)
if loc := direct.FindIndex(body); loc != nil {
t.Errorf("%s:%d — %s", rel, lineOf(body, loc[0]), why)
}
for _, m := range assigned.FindAllSubmatch(body, -1) {
name := regexp.QuoteMeta(string(m[1]))
used := regexp.MustCompile(
`strings\.(Contains|HasPrefix|HasSuffix|EqualFold)\(\s*` + name + `\b` +
`|\b` + name + `\s*==\s*"` + `|"\s*==\s*` + name + `\b`)
if loc := used.FindIndex(body); loc != nil {
t.Errorf("%s:%d — %s (текст ошибки положен в %s)",
rel, lineOf(body, loc[0]), why, m[1])
}
}
}
}
// 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")
}