Files
transcriber/docs/conventions/go-linters.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

243 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Линтеры и механизированные проверки
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
здесь строкой — эта запись его принимает.
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
исходники.
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
будет своя запись, а два круга останутся общими.
Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
Своё здесь — перечень правил и подавлений.
## Границы: где что живёт
Чтобы факт не жил в двух местах:
- **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что
красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять —
в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта
один дом, и он у памятки, потому что её читают прежде работы;
- **как писать код** — соседние записи этой конвенции ([README.md](README.md) —
индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень
ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют
дважды;
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
спрашивать уже не нужно;
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
проверок туда не входят: инструментарий спеками не нормируется, и спека
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
тем же решением. Второй самодельный
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
не числится: проверок над проверками проект не заводит —
[CLAUDE.md](../../CLAUDE.md), «Запреты».
## Два круга: 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`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml``noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
| Правило | Где механизировано |
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
| Правило | Где механизировано |
| --- | --- |
| Запрос и внешний процесс заводятся с контекстом (`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` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.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`) |
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
### Хранилище, документы, секреты, зависимости
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `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` | `.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>`);
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
[../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. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
заменило.