// 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]])), ) } } } } // Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`, // читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре // «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает // с нулевым полем, и первое же сохранение пишет этот ноль поверх значения. // // Мест стало **два** вместо прежних четырёх: захват больше не перечисляет // колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила // ниже держат оставшуюся пару плюс шаг схемы. const ( repoPkg = "internal/adapter/repo/pocketbase" mappingFile = repoPkg + "/record_mapping.go" migrationsPath = repoPkg + "/migrations" stageFile = "internal/entity/stage.go" stateFile = "internal/entity/audio_record.go" serviceFile = "internal/service/transcribe.go" ) // Колонки, которые заводит и заполняет само хранилище: нашего кода они не // касаются. var storageOwned = map[string]bool{"id": true, "created": true, "updated": true} func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) { written := writtenColumns(t) read := readColumns(t) for col := range written { if storageOwned[col] { continue } if !read[col] { t.Errorf( "колонку %q пишет отображение записи, но recordToAudioRecord её не "+ "читает: запись приедет из хранилища без этого поля", col, ) } } for col := range read { if storageOwned[col] { continue } if !written[col] { t.Errorf( "колонку %q читает recordToAudioRecord, но её не пишет ни "+ "applyOwnedByPipeline, ни applyToRecord: поле не сохранится", col, ) } } } func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) { declared := schemaFieldNames(t) for col := range writtenColumns(t) { if storageOwned[col] { continue } if !declared[col] { t.Errorf( "колонка %q пишется отображением записи, но ни один шаг схемы её не "+ "заводит: сохранение отвалится на живой базе", col, ) } } } // Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и // связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не // выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж, // забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по // инварианту проекта не пишется в журнал и не считается в метрику. func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) { body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(") for _, stage := range workingStageIdents(t) { if !strings.Contains(body, "entity."+stage) { t.Errorf( "рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+ "в таблице stepFor: запись с этим рубежом остановится, не начав работы", stage, ) } } } // Обратное направление того же правила: шаг, написанный под рубеж, которого в // дескрипторе нет, недостижим — захват такую запись не выдаст никогда. func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) { body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(") declared := map[string]bool{} for _, stage := range stageIdents(t) { declared[stage] = true } re := regexp.MustCompile(`case entity\.(\w+):`) for _, m := range re.FindAllStringSubmatch(body, -1) { if !declared[m[1]] { t.Errorf( "в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+ "дескрипторе: запись с этим рубежом захвату не выдаётся", m[1], ) } } } // writtenColumns — колонки, которые пишет отображение записи в хранилище. func writtenColumns(t *testing.T) map[string]bool { t.Helper() body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + funcBody(t, mappingFile, "func applyToRecord(") out := map[string]bool{} for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) { out[m[1]] = true } if len(out) == 0 { t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет") } return out } // readColumns — колонки, которые читает обратное отображение. func readColumns(t *testing.T) map[string]bool { t.Helper() body := funcBody(t, mappingFile, "func recordToAudioRecord(") out := map[string]bool{} for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) { out[m[1]] = true } if len(out) == 0 { t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет") } return out } // stageIdents — имена констант рубежей, перечисленных дескриптором. func stageIdents(t *testing.T) []string { t.Helper() out, _ := stageDescriptor(t) return out } // workingStageIdents — то же, но без конечного рубежа: из него запись в работу // не берут. func workingStageIdents(t *testing.T) []string { t.Helper() _, working := stageDescriptor(t) return working } func stageDescriptor(t *testing.T) (all []string, working []string) { t.Helper() body := readFile(t, stageFile) const marker = "var stages = []Stage{" start := strings.Index(body, marker) if start < 0 { t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile) } end := strings.Index(body[start:], "\n}") if end < 0 { t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile) } declared := declaredStates(t) re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`) for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) { if !declared[m[1]] { t.Errorf( "дескриптор называет рубеж %s, которого нет среди объявленных состояний "+ "в %s: перечень схемы разошёлся бы с ним молча", m[1], stateFile, ) continue } all = append(all, m[1]) if !strings.Contains(m[0], "Terminal: true") { working = append(working, m[1]) } } if len(all) == 0 { t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет") } if len(working) == 0 { t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет") } return all, working } // declaredStates — константы рубежей, объявленные доменом. func declaredStates(t *testing.T) map[string]bool { t.Helper() out := map[string]bool{} re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`) for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) { out[m[1]] = true } if len(out) == 0 { t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile) } return out } // --- Чтение исходников ------------------------------------------------------ // 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") }