- main.go и journal_route_test.go переехали в cmd/transcriber без правок содержимого; образ собирает ./cmd/transcriber поимённо - cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает и слушает петлевой адрес: войти без Authelia стало чем - ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа на AAAA, которого нет, и npm ci висел вместо отказа
580 lines
23 KiB
Go
580 lines
23 KiB
Go
// 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")
|
||
}
|