- лестница механизации из пяти ступеней, два круга проверок, перечень правил таблицами по родам, перечень подавлений с причинами, порядок заведения правила; названы остатки правил и отклонённые подъёмы - утверждения «Механизировано» в четырёх записях конвенций и единые точки в architecture.md приведены к сегодняшнему состоянию; изъятие «транспорт знает адаптер хранилища» названо строкой - в журнал дефектов записаны две находки: узнавание конца потока по тексту и правило гейта, обходимое одной лишней строкой
22 KiB
Автопроверки
Чем машина судит код этого проекта: какие свойства доведены до проверки, чем
каждое проверяется, когда оно запускается и что остаётся человеку. Документ —
дом темы ревью 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).
Лестница механизации
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому, чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже в заведении, поэтому прыгать через ступень без нужды не надо.
- Проза конвенции. Свойство названо словами, проверяет человек на каждом ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания каждого прогона и молча перестаёт работать, когда внимание кончилось.
- Настройка готового линтера. Свойство совпало с чужим правилом —
включается строкой в
.golangci.yml. Дешевле всего; ограничение в том, что правило чужое и говорит о том, о чём его написали. - Запрет по имени (
forbidigo,depguard). Свойство выражается через «эту функцию/пакет тут звать нельзя». Дешёво и точно, но требует единой точки, куда запрещённое переносят: запрет без дома оставляет код без способа сделать нужное. - Тест-сканер исходников (
internal/archrules). Свойство — о структуре, а не о вызове: направление зависимостей, согласованность двух перечней, отсутствие идиомы. Пишется руками наgo/parserили регулярном выражении, зато читается как тест и ломается заметно. - Свой шаг проверки (
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.yml → errorlint |
Ошибка не узнаётся сравнением текста сообщения (strings.Contains(err.Error(), …), err.Error() == …) |
internal/archrules → TestОшибкаНеУзнаётсяПоТексту |
| Непроверенное возвращаемое значение ошибки | .golangci.yml → errcheck, включая присваивание в _ (check-blank). Отказ, который решено не проверять, объявляют в exclude-functions поимённо — там сегодня defer Close, os.Remove и send |
| Ошибки — только stdlib, без сторонних пакетов | .golangci.yml → depguard |
Структура и границы
| Правило | Где механизировано |
|---|---|
Ядро (internal/service) не знает ни адаптеров, ни транспортов |
internal/archrules → TestЯдроНеЗнаетОбАдаптерах, TestЯдроНеЗнаетОТранспортах |
Транспорты (controller/http, controller/tg, controller/worker) не знают друг о друге |
internal/archrules → TestТранспортыНеЗнаютДругОДруге |
| Адаптер не знает ни ядра, ни транспортов | internal/archrules → TestАдаптерыНеЗнаютНиЯдра_НиТранспортов |
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | internal/archrules → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами db:"…" самой структуры, и правило было бы зелёным всегда |
Время, вывод, конфигурация
| Правило | Где механизировано |
|---|---|
Время читают clock.Now (метка, UTC) и clock.Start (длительность, монотонные часы) — не time.Now по месту |
.golangci.yml → forbidigo; единая точка — internal/clock |
Вывод идёт через slog, а не fmt.Print* и не встроенными print/println |
.golangci.yml → forbidigo. Не ловит fmt.Fprintln(os.Stdout, …) — первый аргумент по имени функции не судится; остаток прозой в conventions/logging.md |
| Конфигурация приезжает из TOML, а не из окружения | .golangci.yml → forbidigo: os.Getenv, os.LookupEnv, os.Environ, os.ExpandEnv — все четыре, иначе запрет обходится соседним именем |
Форма вызова slog: только пары «ключ-значение», атрибуты (slog.String и прочие) не употребляются вовсе; msg — константа |
.golangci.yml → sloglint (kv-only запрещает атрибуты целиком, а не только смешение) |
Проверки о самих проверках
| Правило | Где механизировано |
|---|---|
Проверка судит ответ по готовому ответу (Result()), а не по живой карте заголовков обработчика |
.golangci.yml → forbidigo с analyze-types, находки только в *_test.go. Судит по типу приёмника (httptest.ResponseRecorder), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем HeaderMap. Остаётся ревью проверка, идущая мимо recorder — через свой http.ResponseWriter |
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | scripts/check_go_version_test.go — 20 сценариев спеки toolchain плюс два свойства самого шага: исход не зависит от установленного go, и шаг не зовёт ни go, ни docker, ни сеть |
Форма кода и файлов вне Go
| Правило | Где механизировано |
|---|---|
| Форматирование исходников | .golangci.yml → gofmt; на pre-commit правится на месте |
| Подозрительные конструкции языка | .golangci.yml → govet, staticcheck, ineffassign, unused |
| Опечатка в комментарии и в тексте ошибки | .golangci.yml → misspell |
| Скрипты оболочки | 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.yml → gitleaks 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(). Сегодня таких мест нет.
Как заводят новое правило
Порядок один и тот же, и последние два шага пропускать нельзя.
- Найти дом. Ступень лестницы выбирается по тому, чем свойство выражается, а не по тому, что проще включить.
- Написать причину рядом. Правило без причины снимают при первом же неудобстве: тот, кто снимает, не знает, что оно ловило.
- Починить находки, а не подавить. Подавление годится, когда правило говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с причиной. Подавление «пока некогда» — это отложенная работа, и её место в каталоге задач, а не в конфиге.
- Проверить мутацией. Внести ровно то нарушение, против которого правило написано, и убедиться, что проверка краснеет и называет место. Правило, принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны они в review.md (журнал 2026-08-11 про недостижимую норму, 2026-08-13 про обходимый текстовый запрет).
- Записать строкой здесь и удалить прозу из конвенции, если правило её заменило.