Линтеры и сканеры взяли на себя то, что было прозой конвенций
- включены sloglint, misspell, depguard; forbidigo получил запреты на чтение времени, окружения и вывод в stdout — каждый по свойству, а не по одному имени: запрет на os.Getenv без соседей обходится os.LookupEnv - заведён internal/archrules — тесты-сканеры: направление зависимостей между ядром, транспортами и адаптерами, узнавание ошибки по тексту в шести формах, и согласованность колонок очереди во всех четырёх местах плюс шаг схемы - каждое правило проверено мутацией, каждое исключение объявлено с причиной
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
// Package archrules — тесты-сканеры исходников для правил, которых не выражает
|
||||
// ни линтер, ни компилятор: направление зависимостей между пакетами и
|
||||
// согласованность перечня колонок очереди.
|
||||
//
|
||||
// Каждое правило здесь — бывшая строка прозы: у него есть детерминированный
|
||||
// оракул, поэтому ему место в наборе проверок, а не в промпте ревью. Перечень
|
||||
// механизированного — docs/autotests.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")
|
||||
}
|
||||
Reference in New Issue
Block a user