diff --git a/.golangci.yml b/.golangci.yml index bbf669e..ad7da6b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,44 +1,106 @@ +# Линтеры проекта. Перечень правил и их дома — docs/autotests.md, +# «Механизировано»; здесь только настройка и «почему именно так». +# +# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign, +# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то, +# что проверяет правило, прозой в конвенциях не остаётся. version: "2" linters: default: standard enable: + # docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As. - errorlint + # docs/conventions/errors.md: ошибки — только stdlib. + - depguard + # docs/conventions/logging.md: форма вызова slog. + - sloglint + # Опечатка в комментарии и в тексте ошибки читается как термин проекта. + - misspell + # Запреты по месту: чем судят ответ в проверках, чем читают время, откуда + # берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже. - forbidigo settings: forbidigo: - # Проверка ответа судит по **готовому ответу**, а не по изменяемому - # состоянию обработчика. `httptest` устроен зеркально настоящему серверу: - # `Header()` отдаёт живую карту, доступную и после записи ответа, а снимок, - # который получит клиент, лежит отдельно и читается через `Result()`. - # Проверка, читающая живую карту, зелена при неработающем коде — класс - # всплывал трижды (docs/review.md, записи 2026-08-10, 2026-08-11 и - # 2026-08-12) и трижды стоил зелёного гейта. - # - # Правило действует только в `*_test.go` — исключение ниже: в самом - # обработчике `Header()` и есть способ отдать заголовок. - # - # Судит оно **по типу приёмника**, а не по печатному тексту вызова - # (`analyze-types`), и в этом весь смысл: запрет на цепочку - # `w.Header().Get` обходится одной лишней строкой — `h := w.Header()`, — - # а также чтением по индексу карты и обходом `range`. По типу под правило - # попадают все эти формы разом. Записать его текстом нельзя ещё и потому, - # что `.Header` носят и запрос (`req.Header.Set` в проверках законен), и - # снимок ответа (`w.Result().Header` — как раз то, к чему правило ведёт). - # - # Приёмник назван поимённо: подставной сервер в проверках отдаёт заголовок - # через `w.Header().Set`, но у него приёмник — `http.ResponseWriter`, и под - # правило он не попадает. - # - # `HeaderMap` — тот же живой снимок прежним именем поля; правило второе, - # потому что о его устарелости говорит `staticcheck` (SA1019), а о том, - # почему по нему не судят ответ, — только это сообщение. + # `analyze-types` включает суждение по типу приёмника, а не по печатному + # тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим + # правилам не мешает: имена пакетов в шаблонах те же. analyze-types: true forbid: + # Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни + # полей, и в разборе постфактум её не найти. Встроенные `print`/`println` + # названы тем же правилом: запрет на одно имя обходится соседним. + - pattern: '^fmt\.Print.*$' + msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)' + - pattern: '^print(ln)?$' + msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)' + # Конфигурация приезжает из TOML. Перечислены все способы прочитать + # окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv` + # одной правкой. Окружение читает только godotenv в main.go — он кладёт + # .env в окружение процесса, а не в настройки. + # + # Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и + # `os.Stdout.WriteString` — первый аргумент по имени функции не судится. + # Этот остаток назван прозой в docs/conventions/logging.md. + - pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$' + msg: 'конфигурация только из TOML (docs/conventions/config.md)' + # Единая точка чтения времени — internal/clock: метка времени в UTC + # (`clock.Now`), измерение длительности с монотонными часами + # (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало + # строками времена из разных зон. + - pattern: '^time\.Now$' + msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md' + # Проверка ответа судит по **готовому ответу**, а не по изменяемому + # состоянию обработчика. `httptest` устроен зеркально настоящему серверу: + # `Header()` отдаёт живую карту, доступную и после записи ответа, а + # снимок, который получит клиент, лежит отдельно и читается через + # `Result()`. Проверка, читающая живую карту, зелена при неработающем + # коде — класс всплывал трижды (docs/review.md, записи 2026-08-10, + # 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта. + # + # Правило судит по типу приёмника, и в этом весь смысл: запрет на + # цепочку `w.Header().Get` обходится одной лишней строкой — + # `h := w.Header()`, — а также чтением по индексу карты и обходом + # `range`. По типу под правило попадают все эти формы разом. Текстом его + # записать нельзя ещё и потому, что `.Header` носят и запрос + # (`req.Header.Set` в проверках законен), и снимок ответа + # (`w.Result().Header` — как раз то, к чему правило ведёт). + # + # Приёмник назван поимённо: подставной сервер в проверках отдаёт + # заголовок через `w.Header().Set`, но у него приёмник — + # `http.ResponseWriter`, и под правило он не попадает. - pattern: '^httptest\.ResponseRecorder\.Header$' msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header' + # `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе, + # потому что об устарелости поля говорит `staticcheck` (SA1019), а о том, + # почему по нему не судят ответ, — только это сообщение. - pattern: '^httptest\.ResponseRecorder\.HeaderMap$' msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header' + + sloglint: + # Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты + # (`slog.String` и прочие) **целиком**, а не только смешение с парами: + # смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное — + # один стиль на весь код, — и записано строкой в + # docs/conventions/logging.md, «Сообщение». + no-mixed-args: true + kv-only: true + # `msg` — константа: сообщение с подставленным значением не сгруппировать + # отбором, а данные для этого и кладут в поля. + static-msg: true + # `key-naming-case` не включаем: словарь полей намеренно смешанный — + # доменные поля `snake_case`, системные домены с точкой (`http.method`, + # `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён». + + depguard: + rules: + main: + deny: + - pkg: github.com/pkg/errors + desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)' + - pkg: github.com/cockroachdb/errors + desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)' + errcheck: # Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не # теряется молча» принимается реализацией, которая его теряет. Отказ, @@ -56,11 +118,33 @@ linters: exclusions: rules: - # `path-except` оставляет находки только в файлах проверок: в рабочем коде - # чтение своей карты заголовков законно. + # Правило о заголовках живёт только в файлах проверок: в рабочем коде + # `Header()` и есть способ отдать заголовок. - linters: - forbidigo path-except: '_test\.go$' + text: 'живой карте заголовков' + # Единая точка чтения времени сама читает время — иначе ей нечем. + - linters: + - forbidigo + path: 'internal/clock/' + text: 'time.Now' + # Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из + # него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему + # процессу. Настройками приложения это не является. Исключение объявлено по + # тексту сообщения, а не по имени функции: правило называет четыре имени, и + # исключение обязано покрывать те же четыре. + - linters: + - forbidigo + path: '_test\.go$' + text: 'конфигурация только из TOML' + # Проверки строят время фикстур, а не метку домена: `time.Now` в них не + # обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного + # обряда на каждый срок захвата в фикстуре и не поймал бы ничего. + - linters: + - forbidigo + path: '_test\.go$' + text: 'time.Now' formatters: enable: diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go new file mode 100644 index 0000000..acda330 --- /dev/null +++ b/internal/archrules/arch_test.go @@ -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") +}