Files
transcriber/internal/archrules/arch_test.go
T
av 91138dd39a точки входа переехали в cmd/, заведена заглушка OIDC для локального входа
- main.go и journal_route_test.go переехали в cmd/transcriber без правок
  содержимого; образ собирает ./cmd/transcriber поимённо
- cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает
  и слушает петлевой адрес: войти без Authelia стало чем
- ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа
  на AAAA, которого нет, и npm ci висел вместо отказа
2026-08-15 20:15:57 +03:00

580 lines
23 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 — тесты-сканеры исходников для правил, которых не выражает
// ни линтер, ни компилятор: направление зависимостей между пакетами и
// согласованность перечня колонок очереди.
//
// Каждое правило здесь — бывшая строка прозы: у него есть детерминированный
// оракул, поэтому ему место в наборе проверок, а не в промпте ревью. Перечень
// механизированного — 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 и хранилище подставляются в точке входа `cmd/transcriber`
// (docs/architecture.md, «Принципы»).
const core = "internal/service"
// Транспорты — входы в ядро. Общее у двух транспортов живёт в ядре, а не в
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
var transports = map[string]bool{
"internal/controller/http": 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, а реализацию подставляет cmd/transcriber",
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) {
const listFile = repoPkg + "/record_list.go"
body := readFile(t, listFile)
for _, value := range declaredStateValues(t) {
if strings.Contains(body, `"`+value+`"`) {
t.Errorf(
"отбор списка называет рубеж %q строкой: рубеж, добавленный "+
"дескриптором, молча не попадёт ни в одно состояние отбора",
value,
)
}
}
for _, fn := range []string{"entity.WorkingStages()", "entity.TerminalStages()"} {
if !strings.Contains(body, fn) {
t.Errorf(
"отбор списка не зовёт %s: перечень рубежей обязан приходить из "+
"дескриптора, а не собираться по месту",
fn,
)
}
}
}
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 — константы рубежей, объявленные доменом.
// declaredStateValues — **значения** рубежей, а не имена их констант: правило
// отбора ищет строковый литерал в чужом файле, и сравнивать его надо со
// значением.
//
// declaredStates рядом отдаёт имена констант — им пользуются правила, читающие
// код, а не строки.
func declaredStateValues(t *testing.T) []string {
t.Helper()
out := []string{}
re := regexp.MustCompile(`(?m)^\tState\w+\s*=\s*"([^"]+)"`)
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
out = append(out, m[1])
}
if len(out) == 0 {
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
}
return out
}
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")
}