# Линтеры и механизированные проверки Конвенция о том, **чем машина читает наш код**: какие свойства доведены до правила, чем каждое проверяется, когда оно запускается и что осталось человеку. Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется здесь строкой — эта запись его принимает. **Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах — линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать юнит-тест и какой у него оракул. Это другой предмет, и живёт он в [../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест обязан проверять, и там же записано требование, чтобы проверка была **способна упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а потому, что они правила: у них нет ни фикстур, ни поведения — они читают исходники. Пока язык у проекта один, и запись названа по нему. Появится второй — у него будет своя запись, а лестница и два круга останутся общими. Устройство ниже **переносимо**: разделы «Лестница механизации», «Два круга» и «Как заводят новое правило» — не особенность transcriber и переносятся в другой Go-проект как есть. Своё здесь — перечень правил и подавлений. ## Границы: где что живёт Чтобы факт не жил в двух местах: - **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять — в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта один дом, и он у памятки, потому что её читают прежде работы; - **как писать код** — соседние записи этой конвенции ([README.md](README.md) — индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют дважды; - **настройка конвейера ревью, вопросы по темам и журнал дефектов** — [../review.md](../review.md). Перечень ниже говорит этим вопросам, чего спрашивать уже не нужно; - **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки версий Go поведение нормировано отдельно, спекой [toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка проекта, у которой есть своя 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](../../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы минуту, и хук отключили бы через день. Полный набор проверок в pre-commit не переносится сознательно; обратное решение — «гонять всё на каждый коммит» — известно и отклонено по этой же причине. ## Механизировано Проверяется командами из [CLAUDE.md](../../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, …)` — первый аргумент по имени функции не судится; остаток прозой в [logging.md](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](../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](../review.md) (журнал 2026-08-11 про недостижимую норму, 2026-08-13 про обходимый текстовый запрет). 5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её заменило.