- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень механизированного пополнен девятью правилами и двумя шагами гейта, названы остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck профилактические — предмета в коде нет - в logging.md и security.md чистка отказа Telegram описана по факту: точка одна и лежит на границе клиента, закрыты все пять путей вместе с логгером самой библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было неверным дважды - в журнал дефектов записаны две находки: отказ скачивания уносил токен бота (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую запись в failed (поймано ревью до коммита) - вопрос темы operations про отмену переформулирован: спрашивать надо не «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста на него не отвечает - в памятке: словарь кодов новых шагов, требование компилятора C у детектора гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
263 lines
30 KiB
Markdown
263 lines
30 KiB
Markdown
# Линтеры и механизированные проверки
|
||
|
||
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
|
||
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
|
||
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
|
||
здесь строкой — эта запись его принимает.
|
||
|
||
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
|
||
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
|
||
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
|
||
[../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`). Второй самодельный
|
||
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
|
||
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
|
||
объявленный долг, а не умолчание.
|
||
|
||
## Лестница механизации
|
||
|
||
Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому,
|
||
чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже
|
||
в заведении, поэтому прыгать через ступень без нужды не надо.
|
||
|
||
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` |
|
||
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||
| Ошибки — только 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:"…"` самой структуры, и правило было бы зелёным всегда |
|
||
|
||
### Отмена и внешний собеседник
|
||
|
||
| Правило | Где механизировано |
|
||
| --- | --- |
|
||
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml` → `noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
|
||
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml` → `contextcheck` |
|
||
| Тело ответа HTTP закрывается | `.golangci.yml` → `bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
|
||
|
||
### Время, вывод, конфигурация
|
||
|
||
| Правило | Где механизировано |
|
||
| --- | --- |
|
||
| Время читают `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`, ни сеть |
|
||
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты |
|
||
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||
|
||
### Форма кода и файлов вне 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`) |
|
||
|
||
### Хранилище, документы, секреты, зависимости
|
||
|
||
| Правило | Где механизировано |
|
||
| --- | --- |
|
||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `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`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
|
||
| `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`;
|
||
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
|
||
`context.Background()` там, где контекст был доводом, — но метод, у которого
|
||
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
|
||
остаётся человеку;
|
||
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
|
||
добавленного после неё файла статус `A`, и правка такого файла законна — он
|
||
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||
молчит на всём каталоге, и на подозрении база задаётся руками
|
||
(`task migrations BASE=<rev>`);
|
||
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
||
`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 про обходимый текстовый запрет).
|
||
|
||
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
|
||
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
|
||
мутация, задевшая сразу два правила, покажет только первое: так молчали
|
||
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
|
||
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
|
||
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
|
||
заменило.
|