// Package archrules — тесты-сканеры исходников для правил, которых не выражает // ни линтер, ни компилятор: направление зависимостей между пакетами и // согласованность перечня колонок очереди. // // Каждое правило здесь — бывшая строка прозы: у него есть детерминированный // оракул, поэтому ему место в наборе проверок, а не в промпте ревью. Перечень // механизированного — docs/conventions/go-linters.md. // // Пакет тестовый целиком: рабочего кода в нём нет и быть не должно. package archrules import ( "bytes" "go/parser" "go/printer" "go/token" "os" "path/filepath" "regexp" "strings" "testing" ) const ( modulePath = "git.vakhrushev.me/av/transcriber" // repoRoot — корень репозитория относительно каталога пакета. repoRoot = "../.." ) // Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а // ffmpeg, Yandex, Telegram и хранилище подставляются в `main.go` // (docs/architecture.md, «Принципы»). const core = "internal/service" // Транспорты — входы в ядро. Общее у двух транспортов живёт в ядре, а не в // одном из них: иначе второй начинает зависеть от первого и тащит его целиком. var transports = map[string]bool{ "internal/controller/http": true, "internal/controller/tg": true, "internal/controller/worker": true, } const adapterPrefix = "internal/adapter/" // Пакеты, названные константами выше, обязаны существовать. Иначе правила ниже // стали бы вечно зелёными от одного `git mv`: обход по отсутствующему ключу // карты идёт ноль раз и молчит. func TestПакетыПравилСуществуют(t *testing.T) { dirs := packageDirs(t) for pkg := range transports { if !dirs[pkg] { t.Errorf( "транспорт %s не найден в дереве: правило о транспортах потеряло "+ "предмет — переименуй его в этом файле", pkg, ) } } if !dirs[core] { t.Errorf( "ядро %s не найдено в дереве: правила о ядре потеряли предмет — "+ "переименуй его в этом файле", core, ) } var adapters int for dir := range dirs { if strings.HasPrefix(dir, adapterPrefix) { adapters++ } } if adapters == 0 { t.Errorf("под %s не найдено ни одного пакета: правило об адаптерах потеряло предмет", adapterPrefix) } } func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) { for _, imp := range internalImports(t)[core] { if strings.HasPrefix(imp, adapterPrefix) { t.Errorf( "%s импортирует адаптер %s: ядро зависит от интерфейсов "+ "internal/contract, а реализацию подставляет main.go", core, imp, ) } } } func TestЯдроНеЗнаетОТранспортах(t *testing.T) { for _, imp := range internalImports(t)[core] { if transports[imp] { t.Errorf( "%s импортирует транспорт %s: зависимость направлена не туда, "+ "ядро не знает, кто его позвал", core, imp, ) } } } 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 !strings.HasPrefix(pkg, adapterPrefix) { continue } for _, imp := range imports { if imp == core || transports[imp] { t.Errorf( "адаптер %s импортирует %s: адаптер реализует интерфейс "+ "internal/contract и о вызывающем не знает", pkg, imp, ) } } } } // Ошибку узнают `errors.Is` и `errors.As`. Сравнение текста сообщения ловит // заодно и чужую ошибку с тем же текстом, а при смене текста в библиотеке // перестаёт ловить вообще — молча. `errorlint` видит `err == ErrX` и приведение // типа, но матчинг по тексту не видит: его ловит это правило. // // Прецедент: клиент SpeechKit узнавал конец потока сравнением `err.Error() == // "EOF"` — правилом это закрыто 2026-08-13. // // Форм записи одного и того же условия много, и правило перечисляет их все: // равенство и **неравенство**, обратный порядок операндов, `switch` по тексту и // поиск подстроки любым способом. Отрицание — самая частая форма, и текстовый // запрет, ловящий только `==`, обходился бы ею молча. // // Ищутся все вхождения, а не первое: два места в одном файле иначе починили бы // по одному за прогон. func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) { patterns := []*regexp.Regexp{ regexp.MustCompile(`\.Error\(\)\s*(==|!=)`), regexp.MustCompile(`(==|!=)\s*[\w.]+\.Error\(\)`), regexp.MustCompile(`switch\s+[\w.]+\.Error\(\)`), regexp.MustCompile(`strings\.\w+\([^)]*\.Error\(\)`), regexp.MustCompile(`regexp\.\w+\([^)]*\.Error\(\)`), regexp.MustCompile(`\.MatchString\([^)]*\.Error\(\)`), } for _, path := range goFiles(t) { // Комментарии сняты разбором: объяснение, приводящее запрещённую форму // в пример, — не код, и краснеть на нём правило не должно. body := []byte(sourceWithoutComments(t, path)) rel, err := filepath.Rel(repoRoot, path) if err != nil { t.Fatalf("отношу путь %s: %v", path, err) } for _, re := range patterns { for _, loc := range re.FindAllIndex(body, -1) { t.Errorf( "%s:%d — ошибку узнают errors.Is и errors.As, а не по тексту сообщения: %q", rel, lineOf(body, loc[0]), strings.TrimSpace(string(body[loc[0]:loc[1]])), ) } } } } // Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и // компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major). // Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата // нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения — // поле теряется только у задачи, попавшей к воркеру. // // Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса, // структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос // поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а // не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие // выполнялось бы само собой. const ( repoPkg = "internal/adapter/repo/pocketbase" acquireFile = repoPkg + "/transcript_job_repo.go" mappingFile = repoPkg + "/job_mapping.go" migrationsPath = repoPkg + "/migrations" ) // Колонки, которые заводит и заполняет само хранилище: перечня запроса они // касаются, а нашего кода — нет. var storageOwned = map[string]bool{"id": true, "created": true, "updated": true} func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) { query := acquireColumnNames(t) row := rowColumnNames(t) for _, col := range query { if !row[col] { t.Errorf( "колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+ "она приедет нулевой, и первый Save затрёт сохранённое значение", col, ) } delete(row, col) } for col := range row { t.Errorf( "колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+ "читает, и поле остаётся нулевым", col, ) } } func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) { declared := schemaFieldNames(t) for _, col := range acquireColumnNames(t) { if col == "id" { continue // ключ заводит само хранилище, шаг схемы его не объявляет } if !declared[col] { t.Errorf( "колонка %q читается захватом, но ни один шаг схемы её не заводит: "+ "запрос отвалится на живой базе", col, ) } } } // Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку, // `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле: // в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие // выполнялось бы само собой — правило было бы зелёным всегда. func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) { write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + funcBody(t, mappingFile, "func applyToRecord(") read := funcBody(t, mappingFile, "func recordToJob(") for _, col := range acquireColumnNames(t) { if storageOwned[col] { continue // эти колонки заводит и заполняет само хранилище } if !strings.Contains(write, `"`+col+`"`) { t.Errorf( "колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+ "ни applyToRecord: путь через запись коллекции её потеряет", col, ) } if !strings.Contains(read, `"`+col+`"`) { t.Errorf( "колонку %q читает захват, но recordToJob её не читает: задача, "+ "прочитанная не захватом, приедет без этого поля", col, ) } } } // Пятое условие того же инварианта: колонка, доехавшая до структуры захвата, // обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам, // поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь, // приезжает из захвата прочитанным и теряется на последнем шаге. func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) { body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()") for _, field := range rowFieldNames(t) { if !strings.Contains(body, "r."+field) { t.Errorf( "поле %s структуры захвата не читается в toJob: колонка приедет из "+ "запроса, но в задачу не попадёт", field, ) } } } // --- Чтение исходников ------------------------------------------------------ // acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она // склеена из строковых литералов, поэтому берётся текстом, а не разбором типов: // значение константы известно на месте. func acquireColumnNames(t *testing.T) []string { t.Helper() body := readFile(t, acquireFile) const marker = "const acquireColumns = " start := strings.Index(body, marker) if start < 0 { t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile) } tail := body[start+len(marker):] end := strings.Index(tail, "`\n") if end < 0 { t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile) } var cols []string for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") { if col := strings.TrimSpace(chunk); col != "" { cols = append(cols, col) } } if len(cols) == 0 { t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет") } return cols } // rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`. func rowColumnNames(t *testing.T) map[string]bool { t.Helper() out := map[string]bool{} for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) { out[m[1]] = true } if len(out) == 0 { t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет") } return out } // rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым // обращается `toJob`. func rowFieldNames(t *testing.T) []string { t.Helper() var out []string for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) { out = append(out, m[1]) } if len(out) == 0 { t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет") } return out } // rowStruct — текст объявления структуры `acquiredRow`. func rowStruct(t *testing.T) string { t.Helper() body := readFile(t, mappingFile) start := strings.Index(body, "type acquiredRow struct {") if start < 0 { t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile) } end := strings.Index(body[start:], "\n}") if end < 0 { t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile) } return body[start : start+end] } // funcBody — текст тела функции от её заголовка до закрывающей скобки в первой // позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило, // потерявшее предмет, обязано краснеть, а не зеленеть. func funcBody(t *testing.T, file, header string) string { t.Helper() body := readFile(t, file) start := strings.Index(body, header) if start < 0 { t.Fatalf("в %s нет %s: правило потеряло предмет", file, header) } end := strings.Index(body[start:], "\n}") if end < 0 { t.Fatalf("не нашёл конец %s в %s", header, file) } return body[start : start+end] } // schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в // любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг, // который её добавил, а переписывать применённый шаг нельзя. func schemaFieldNames(t *testing.T) map[string]bool { t.Helper() dir := filepath.Join(repoRoot, migrationsPath) entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("читаю каталог шагов схемы: %v", err) } re := regexp.MustCompile(`Name:\s*"([^"]+)"`) out := map[string]bool{} for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { continue } body, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatalf("читаю %s: %v", e.Name(), err) } for _, m := range re.FindAllStringSubmatch(string(body), -1) { out[m[1]] = true } } if len(out) == 0 { t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет") } return out } func readFile(t *testing.T, rel string) string { t.Helper() body, err := os.ReadFile(filepath.Join(repoRoot, rel)) if err != nil { t.Fatalf("читаю %s: %v", rel, err) } return string(body) } // 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 { if after, ok := strings.CutPrefix(strings.Trim(imp.Path.Value, `"`), modulePath+"/"); ok { out[rel] = append(out[rel], after) } } } if len(out) == 0 { t.Fatal("не найдено ни одного файла с внутренними импортами: правило потеряло предмет") } return out } // sourceWithoutComments — исходник без комментариев: файл разбирается без них и // печатается заново. Снимать комментарии текстом нельзя — строковый литерал со // знаками `//` внутри перестал бы читаться. func sourceWithoutComments(t *testing.T, path string) string { t.Helper() fset := token.NewFileSet() f, err := parser.ParseFile(fset, path, nil, 0) if err != nil { t.Fatalf("разбираю %s: %v", path, err) } var buf bytes.Buffer if err := printer.Fprint(&buf, fset, f); err != nil { t.Fatalf("печатаю %s: %v", path, err) } return buf.String() } // packageDirs — каталоги репозитория с рабочим кодом на Go, путями от корня // модуля. Каталог без импортов внутрь модуля тоже считается: правила о предмете // говорят, а не о его зависимостях. func packageDirs(t *testing.T) map[string]bool { t.Helper() out := map[string]bool{} for _, path := range goFiles(t) { rel, err := filepath.Rel(repoRoot, filepath.Dir(path)) if err != nil { t.Fatalf("отношу путь %s: %v", path, err) } out[rel] = true } return out } // goFiles — все нерабочие каталоги отброшены, файлы проверок тоже: правила // говорят о рабочем коде. 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 ".git", "data", "docker", "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 } func lineOf(body []byte, offset int) int { return 1 + strings.Count(string(body[:offset]), "\n") }