Files
transcriber/internal/archrules/arch_test.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

692 lines
29 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,
)
}
}
}
}
// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер
// хранилища, снято вместе с предметом: HTTP-поверхность была роутером
// встроенного хранилища, а стала своей, и правило на это направление заводится
// впервые.
//
// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён
// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом
// `internal/contract`, а реализацию подставляет точка входа.
func TestТранспортыНеЗнаютАдаптеров(t *testing.T) {
for pkg, imports := range internalImports(t) {
if !transports[pkg] {
continue
}
for _, imp := range imports {
if strings.HasPrefix(imp, adapterPrefix) {
t.Errorf(
"транспорт %s импортирует адаптер %s: реализацию подставляет "+
"cmd/transcriber, а транспорт знает только internal/contract",
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]])),
)
}
}
}
}
// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
// полем, и первое же сохранение пишет этот ноль поверх значения.
//
// Отображение работает **по имени колонки** — именованные параметры запроса и
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
// отображения нет вовсе.
const (
repoPkg = "internal/adapter/repo/sqlite"
mappingFile = repoPkg + "/record_mapping.go"
migrationsPath = repoPkg + "/migrations"
stageFile = "internal/entity/stage.go"
stateFile = "internal/entity/audio_record.go"
serviceFile = "internal/service/transcribe.go"
)
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
written := writtenColumns(t)
read := readColumns(t)
for col := range written {
if !read[col] {
t.Errorf(
"колонку %q пишет отображение записи, но readRecordColumns её не "+
"читает: запись приедет из базы без этого поля",
col,
)
}
}
for col := range read {
if !written[col] {
t.Errorf(
"колонку %q читает readRecordColumns, но её не пишет ни "+
"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
col,
)
}
}
}
// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
// пишет этот ноль поверх сохранённого.
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
targets := readTargets(t)
used := rowFieldsTakenByEntity(t)
for field, column := range targets {
if !used[field] {
t.Errorf(
"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
"значение не доедет до сущности, а ближайшее сохранение запишет "+
"нулевое поверх сохранённого",
column, field,
)
}
}
for field := range used {
if _, ok := targets[field]; !ok {
t.Errorf(
"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
"колонки в него не кладёт: сущность получит нулевое значение всегда",
field,
)
}
}
}
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t)
for col := range writtenColumns(t) {
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],
)
}
}
}
// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
// writtenColumns — колонки, которые пишет отображение записи в базу.
func writtenColumns(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
funcBody(t, mappingFile, "func writeRecord(")
out := map[string]bool{}
for _, m := range columnKey.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 readRecordColumns(")
out := map[string]bool{}
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
}
return out
}
// readTarget — колонка чтения и поле сырой строки, куда она ложится.
var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`)
// rowFieldUse — обращение к полю сырой строки при сборке сущности.
var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`)
// readTargets — поле сырой строки в имя колонки, которая в него читается.
func readTargets(t *testing.T) map[string]string {
t.Helper()
body := funcBody(t, mappingFile, "func readRecordColumns(")
out := map[string]string{}
for _, m := range readTarget.FindAllStringSubmatch(body, -1) {
out[m[2]] = m[1]
}
if len(out) == 0 {
t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет")
}
return out
}
// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности.
func rowFieldsTakenByEntity(t *testing.T) map[string]bool {
t.Helper()
body := funcBody(t, mappingFile, "func rowToAudioRecord(")
out := map[string]bool{}
for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) {
out[m[1]] = true
}
if len(out) == 0 {
t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет")
}
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 собирает имена колонок аудиозаписи, заведённых шагами схемы.
//
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
// поэтому объединённый — по всем шагам сразу.
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)
}
column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`)
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 _, block := range tableBlocks(string(body), recordsTable) {
for _, m := range column.FindAllStringSubmatch(block, -1) {
out[m[1]] = true
}
}
}
if len(out) == 0 {
t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
}
return out
}
// recordsTable — имя таблицы аудиозаписей в шагах схемы.
const recordsTable = "audio_records"
// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до
// закрывающей скобки в начале строки.
func tableBlocks(body, table string) []string {
var out []string
marker := "CREATE TABLE " + table + " ("
for {
start := strings.Index(body, marker)
if start < 0 {
return out
}
body = body[start+len(marker):]
end := strings.Index(body, "\n\t\t)")
if end < 0 {
return out
}
out = append(out, body[:end])
body = body[end:]
}
}
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")
}