Files
transcriber/docs/autotests.md
T
av 37ccda3677 docs: autotests.md стал переносимым документом об автопроверках
- лестница механизации из пяти ступеней, два круга проверок, перечень правил
  таблицами по родам, перечень подавлений с причинами, порядок заведения
  правила; названы остатки правил и отклонённые подъёмы
- утверждения «Механизировано» в четырёх записях конвенций и единые точки в
  architecture.md приведены к сегодняшнему состоянию; изъятие «транспорт знает
  адаптер хранилища» названо строкой
- в журнал дефектов записаны две находки: узнавание конца потока по тексту и
  правило гейта, обходимое одной лишней строкой
2026-08-13 09:02:19 +03:00

22 KiB
Raw Blame History

Автопроверки

Чем машина судит код этого проекта: какие свойства доведены до проверки, чем каждое проверяется, когда оно запускается и что остаётся человеку. Документ — дом темы ревью autotests: проход, которому эта тема досталась, читает его, а не перечисляет инструменты по памяти.

Документ переносимый: устройство ниже — не особенность transcriber, а способ вести автопроверки в Go-проекте, и разделы «Лестница механизации», «Два круга» и «Как заводят новое правило» переносятся в другой проект как есть. Своё здесь — перечень правил и подавлений; он назван так, чтобы отличать переносимое от местного.

Тема заведена 2026-08-13. Прежде перечень лежал разделом «Механизировано» в conventions/README.md, и это был чужой дом: конвенции говорят, как писать код, а здесь речь об инструментах, которые его читают.

Границы дома

Что здесь есть и чего здесь нет — чтобы факт не жил в двух местах:

  • семантика гейта — команда целиком, база диффа, словарь кодов выхода, что красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять — в CLAUDE.md, раздел «Гейт». Здесь это не повторяется: у гейта один дом, и он у памятки, потому что её читают прежде работы;
  • как писать кодconventions/. Свойство, ставшее правилом, оттуда удаляется и попадает в перечень ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют дважды;
  • настройка конвейера ревью и журнал дефектовreview.md. Оттуда берутся вопросы по темам, и перечень ниже говорит этим вопросам, чего спрашивать уже не нужно;
  • поведение сервиса — нормативные спеки openspec/specs/. У шага сверки версий Go поведение нормировано отдельно, спекой toolchain: это единственная проверка проекта, у которой есть своя capability, и потому единственная, чьи сценарии проверяются построчно (scripts/check_go_version_test.go).

Лестница механизации

Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому, чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже в заведении, поэтому прыгать через ступень без нужды не надо.

  1. Проза конвенции. Свойство названо словами, проверяет человек на каждом ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания каждого прогона и молча перестаёт работать, когда внимание кончилось.
  2. Настройка готового линтера. Свойство совпало с чужим правилом — включается строкой в .golangci.yml. Дешевле всего; ограничение в том, что правило чужое и говорит о том, о чём его написали.
  3. Запрет по имени (forbidigo, depguard). Свойство выражается через «эту функцию/пакет тут звать нельзя». Дешёво и точно, но требует единой точки, куда запрещённое переносят: запрет без дома оставляет код без способа сделать нужное.
  4. Тест-сканер исходников (internal/archrules). Свойство — о структуре, а не о вызове: направление зависимостей, согласованность двух перечней, отсутствие идиомы. Пишется руками на go/parser или регулярном выражении, зато читается как тест и ломается заметно.
  5. Свой шаг проверки (scripts/, шаги Taskfile.yml). Свойство выходит за пределы кода на Go: версия инструмента, форма Dockerfile, раскладка документов. Дороже всех — у шага появляется своя норма и свои тесты.

Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о заголовках ответа, которое сначала запретило текст \.Header\(\)\.Get, а обходилось присваиванием в переменную. Правило переписано на суждение по типу приёмника (analyze-types), и это уже настоящая третья ступень.

Два круга: pre-commit и гейт

Проверки идут двумя кругами, и круг выбирается по цене прогона.

pre-commit (lefthook.yml) гейт (task gate)
Когда на каждый коммит перед тем как считать задачу сделанной
На чём на затронутых файлах на всём дереве
Сколько идёт около секунды десятки секунд
Что делает с находкой gofmt правит и добавляет в коммит, прочее роняет коммит роняет прогон

Перечень работ pre-commit и то, что остаётся только гейту, — в CLAUDE.md, раздел «Гейт». Здесь важен принцип: pre-commit не подменяет гейт. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы минуту, и хук отключили бы через день.

Полный набор проверок в pre-commit не переносится сознательно; обратное решение — «гонять всё на каждый коммит» — известно и отклонено по этой же причине.

Механизировано

Проверяется командами из CLAUDE.md; прозой не дублируется и в промптах ревью не пересказывается.

Ошибки и отказы

Правило Где механизировано
Сравнение ошибок через errors.Is и errors.As, не == и не приведением типа .golangci.ymlerrorlint
Ошибка не узнаётся сравнением текста сообщения (strings.Contains(err.Error(), …), err.Error() == …) internal/archrulesTestОшибкаНеУзнаётсяПоТексту
Непроверенное возвращаемое значение ошибки .golangci.ymlerrcheck, включая присваивание в _ (check-blank). Отказ, который решено не проверять, объявляют в exclude-functions поимённо — там сегодня defer Close, os.Remove и send
Ошибки — только stdlib, без сторонних пакетов .golangci.ymldepguard

Структура и границы

Правило Где механизировано
Ядро (internal/service) не знает ни адаптеров, ни транспортов internal/archrulesTestЯдроНеЗнаетОбАдаптерах, TestЯдроНеЗнаетОТранспортах
Транспорты (controller/http, controller/tg, controller/worker) не знают друг о друге internal/archrulesTestТранспортыНеЗнаютДругОДруге
Адаптер не знает ни ядра, ни транспортов internal/archrulesTestАдаптерыНеЗнаютНиЯдра_НиТранспортов
Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу internal/archrules → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами db:"…" самой структуры, и правило было бы зелёным всегда

Время, вывод, конфигурация

Правило Где механизировано
Время читают clock.Now (метка, UTC) и clock.Start (длительность, монотонные часы) — не time.Now по месту .golangci.ymlforbidigo; единая точка — internal/clock
Вывод идёт через slog, а не fmt.Print* и не встроенными print/println .golangci.ymlforbidigo. Не ловит fmt.Fprintln(os.Stdout, …) — первый аргумент по имени функции не судится; остаток прозой в conventions/logging.md
Конфигурация приезжает из TOML, а не из окружения .golangci.ymlforbidigo: os.Getenv, os.LookupEnv, os.Environ, os.ExpandEnv — все четыре, иначе запрет обходится соседним именем
Форма вызова slog: только пары «ключ-значение», атрибуты (slog.String и прочие) не употребляются вовсе; msg — константа .golangci.ymlsloglint (kv-only запрещает атрибуты целиком, а не только смешение)

Проверки о самих проверках

Правило Где механизировано
Проверка судит ответ по готовому ответу (Result()), а не по живой карте заголовков обработчика .golangci.ymlforbidigo с analyze-types, находки только в *_test.go. Судит по типу приёмника (httptest.ResponseRecorder), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем HeaderMap. Остаётся ревью проверка, идущая мимо recorder — через свой http.ResponseWriter
Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью scripts/check_go_version_test.go — 20 сценариев спеки toolchain плюс два свойства самого шага: исход не зависит от установленного go, и шаг не зовёт ни go, ни docker, ни сеть

Форма кода и файлов вне Go

Правило Где механизировано
Форматирование исходников .golangci.ymlgofmt; на pre-commit правится на месте
Подозрительные конструкции языка .golangci.ymlgovet, staticcheck, ineffassign, unused
Опечатка в комментарии и в тексте ошибки .golangci.ymlmisspell
Скрипты оболочки Taskfile.yml → шаг shell (shellcheck), он же на pre-commit
Форма Dockerfile Taskfile.yml → шаг dockerfile (hadolint), он же на pre-commit
Одно число версии Go в go.mod, Dockerfile, CLAUDE.md и README.md Taskfile.yml → шаг go-version (scripts/check-go-version.sh)

Хранилище, документы, секреты, зависимости

Правило Где механизировано
Раскладка документов, битые ссылки, изменённый шаг схемы без правки database.md docs.py check; каталог шагов задаёт ключ migrations в docs/.docs.json
Согласованность каталога задач, форма openspec/config.yaml tasks.py check, openspec.py check
Секреты в коммите lefthook.ymlgitleaks git --staged
Достижимая из кода уязвимость в зависимостях Taskfile.yml → шаг vulns (govulncheck ./...)

Не названное здесь место механизации означает, что проход по конвенциям будет добросовестно проверять уже проверенное.

Подавления: что и почему

Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с причиной. Причина живёт строкой рядом с подавлением (в .golangci.yml или Taskfile.yml), а здесь — их перечень, чтобы видеть все разом.

Подавлено Где Почему
errcheck на defer Close, os.Remove и send .golangci.yml, exclude-functions Отказ, который решено не проверять, объявляют поимённо — так он заметен
Правило о заголовках вне *_test.go .golangci.yml, exclusions В рабочем коде Header() и есть способ отдать заголовок
time.Now внутри internal/clock там же Единой точке чтения времени нечем читать время иначе
Чтение времени и окружения в *_test.go там же Проверка строит вход прогона — фикстуру времени, PATH, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре
SC1007 в scripts/check-go-version.sh директива в скрипте Ложное срабатывание на идиому CDPATH= cd, которая защищает cd от чужого CDPATH
DL3007 (alpine:latest) Taskfile.yml, шаг dockerfile Открытая задача pin-runtime-image-base; до её решения шаг краснел бы на известном
DL3018 (закрепить версии apk) там же Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели

Что остаётся прозой

Из перечисленного в записях конвенций правилом выражено не всё. Прозой остаётся то, чему нет ни готового правила, ни детерминированного оракула: уровень лога по адресату, единая логирующая точка на доменной границе, словарь имён полей, канонический вид идентификатора, естественные ключи у деталей. Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и есть первая ступень лестницы, и подъём с неё всегда выигрыш.

Названы поимённо и остатки правил — то, что правило не ловит и потому осталось человеку:

  • вывод в stdout через fmt.Fprintln(os.Stdout, …) и os.Stdout.WriteString: forbidigo судит по имени вызванной функции, а не по её первому аргументу;
  • проверка, судящая ответ мимо recorder — через свой http.ResponseWriter;
  • направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно — controller/http импортирует адаптер хранилища, потому что HTTP-поверхность и есть роутер этого хранилища. Изъятие названо в architecture.md, «Принципы», и правила на это направление нет.

Отдельно названы правила, чей подъём отклонён:

  • key-naming-case у sloglint — словарь полей намеренно смешанный: доменные поля snake_case, системные домены с точкой (http.method, ext.service);
  • msg-style: lowercased у sloglint — это ровно конвенция «msg — короткая константа в нижнем регистре», но код называет сообщения предложениями с заглавной, и это объявленное Расхождение. Цена подъёма — переписать больше ста вызовов, и она не заплачена;
  • закрепление версий пакетов apk (DL3018) — см. подавления выше.

Кандидат, ждущий решения: clock.Now и clock.Start отдают один тип, поэтому time.Since(clock.Now()) компилируется и молча меряет длительность настенными часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у Start свой тип с методом Elapsed(). Сегодня таких мест нет.

Как заводят новое правило

Порядок один и тот же, и последние два шага пропускать нельзя.

  1. Найти дом. Ступень лестницы выбирается по тому, чем свойство выражается, а не по тому, что проще включить.
  2. Написать причину рядом. Правило без причины снимают при первом же неудобстве: тот, кто снимает, не знает, что оно ловило.
  3. Починить находки, а не подавить. Подавление годится, когда правило говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с причиной. Подавление «пока некогда» — это отложенная работа, и её место в каталоге задач, а не в конфиге.
  4. Проверить мутацией. Внести ровно то нарушение, против которого правило написано, и убедиться, что проверка краснеет и называет место. Правило, принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны они в review.md (журнал 2026-08-11 про недостижимую норму, 2026-08-13 про обходимый текстовый запрет).
  5. Записать строкой здесь и удалить прозу из конвенции, если правило её заменило.