# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md, # «Механизировано»; здесь только настройка и «почему именно так». # # Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign, # staticcheck, unused. Сверх него включено то, что механизирует конвенции: то, # что проверяет правило, прозой в конвенциях не остаётся. version: "2" linters: default: standard enable: # docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As. - errorlint # docs/conventions/errors.md: ошибки — только stdlib. - depguard # docs/conventions/logging.md: форма вызова slog. - sloglint # Опечатка в комментарии и в тексте ошибки читается как термин проекта. - misspell # Запреты по месту: чем судят ответ в проверках, чем читают время, откуда # берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже. - forbidigo # Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с # контекстом. Инвариант «принятая запись не теряется молча» держится # остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый # без контекста, переживает остановку воркера и дожёвывает чужую запись. - noctx # Контекст приезжает сверху, а не заводится по месту. `context.Background()` # внутри адаптера обрывает цепочку отмены ровно на границе с платным # внешним сервисом — там, где отмена и нужна. - contextcheck # Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close` # объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного # `Close` этим списком не отличается. - bodyclose # `return nil` после проверенной ошибки — это молчаливая потеря отказа, # прямо запрещённая инвариантом об очереди (CLAUDE.md, major). - nilerr # Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное # чтение в пустой результат. - rowserrcheck # `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение. - sqlclosecheck # Форма утверждений в проверках: перепутанные местами «ожидалось/получено», # `assert` там, где после провала продолжать нельзя, `require` из горутины. - testifylint # Подавление — это решение: строчное `//nolint` обязано называть линтер и # причину, а протухшее подавление обязано краснеть. Тот же порядок, что у # подавлений в этом файле, но применённый к комментариям в коде. - nolintlint settings: forbidigo: # `analyze-types` включает суждение по типу приёмника, а не по печатному # тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим # правилам не мешает: имена пакетов в шаблонах те же. analyze-types: true forbid: # Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни # полей, и в разборе постфактум её не найти. Встроенные `print`/`println` # названы тем же правилом: запрет на одно имя обходится соседним. - pattern: '^fmt\.Print.*$' msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)' - pattern: '^print(ln)?$' msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)' # Конфигурация приезжает из TOML. Перечислены все способы прочитать # окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv` # одной правкой. Окружение читает только godotenv в точке входа # `cmd/transcriber` — он кладёт .env в окружение процесса, а не в # настройки. # # Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и # `os.Stdout.WriteString` — первый аргумент по имени функции не судится. # Этот остаток назван прозой в docs/conventions/logging.md. - pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$' msg: 'конфигурация только из TOML (docs/conventions/config.md)' # Единая точка чтения времени — internal/clock: метка времени в UTC # (`clock.Now`), измерение длительности с монотонными часами # (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало # строками времена из разных зон. - pattern: '^time\.Now$' msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md' # Проверка ответа судит по **готовому ответу**, а не по изменяемому # состоянию обработчика. `httptest` устроен зеркально настоящему серверу: # `Header()` отдаёт живую карту, доступную и после записи ответа, а # снимок, который получит клиент, лежит отдельно и читается через # `Result()`. Проверка, читающая живую карту, зелена при неработающем # коде — класс всплывал трижды (docs/review.md, записи 2026-08-10, # 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта. # # Правило судит по типу приёмника, и в этом весь смысл: запрет на # цепочку `w.Header().Get` обходится одной лишней строкой — # `h := w.Header()`, — а также чтением по индексу карты и обходом # `range`. По типу под правило попадают все эти формы разом. Текстом его # записать нельзя ещё и потому, что `.Header` носят и запрос # (`req.Header.Set` в проверках законен), и снимок ответа # (`w.Result().Header` — как раз то, к чему правило ведёт). # # Приёмник назван поимённо: подставной сервер в проверках отдаёт # заголовок через `w.Header().Set`, но у него приёмник — # `http.ResponseWriter`, и под правило он не попадает. - pattern: '^httptest\.ResponseRecorder\.Header$' msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header' # `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе, # потому что об устарелости поля говорит `staticcheck` (SA1019), а о том, # почему по нему не судят ответ, — только это сообщение. - pattern: '^httptest\.ResponseRecorder\.HeaderMap$' msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header' sloglint: # Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты # (`slog.String` и прочие) **целиком**, а не только смешение с парами: # смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное — # один стиль на весь код, — и записано строкой в # docs/conventions/logging.md, «Сообщение». no-mixed-args: true kv-only: true # `msg` — константа: сообщение с подставленным значением не сгруппировать # отбором, а данные для этого и кладут в поля. static-msg: true # `key-naming-case` не включаем: словарь полей намеренно смешанный — # доменные поля `snake_case`, системные домены с точкой (`http.method`, # `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён». depguard: rules: main: deny: - pkg: github.com/pkg/errors desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)' - pkg: github.com/cockroachdb/errors desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)' nolintlint: # Подавление без причины снимают при первом же неудобстве: снимающий не # знает, что оно ловило. Те же два требования, что у подавлений в этом # файле, — имя линтера и причина строкой. require-explanation: true require-specific: true # Подавление, которому нечего подавлять, — след починенного места, и # краснеть оно обязано: иначе перечень подавлений врёт. allow-unused: false errcheck: # Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не # теряется молча» принимается реализацией, которая его теряет. Отказ, # который решено не проверять, теперь объявляют ниже поимённо — заметно. check-blank: true # Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому # `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`. check-type-assertions: true exclude-functions: # Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки - (io.Closer).Close - (*database/sql.DB).Close - (*os.File).Close - (io.ReadCloser).Close - os.Remove # Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны, # а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа # закрытия нет. - (*database/sql.Rows).Close # Откат транзакции отложенным вызовом. Успешно завершённая транзакция # отвечает на него «уже закончена», и проверка этого отказа означала бы # разбор штатного исхода. - (*database/sql.Tx).Rollback # Запись тела ответа. Отказ здесь значит оборванное соединение, и # сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом # браузере наполняла бы журнал ничем. - (*encoding/json.Encoder).Encode - (net/http.ResponseWriter).Write exclusions: rules: # Правило о заголовках живёт только в файлах проверок: в рабочем коде # `Header()` и есть способ отдать заголовок. - linters: - forbidigo path-except: '_test\.go$' text: 'живой карте заголовков' # Единая точка чтения времени сама читает время — иначе ей нечем. - linters: - forbidigo path: 'internal/clock/' text: 'time.Now' # Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из # него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему # процессу. Настройками приложения это не является. Исключение объявлено по # тексту сообщения, а не по имени функции: правило называет четыре имени, и # исключение обязано покрывать те же четыре. - linters: - forbidigo path: '_test\.go$' text: 'конфигурация только из TOML' # Проверки строят время фикстур, а не метку домена: `time.Now` в них не # обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного # обряда на каждый срок захвата в фикстуре и не поймал бы ничего. - linters: - forbidigo path: '_test\.go$' text: 'time.Now' # `httptest.NewRequest` строит фикстуру для обработчика в том же процессе: # внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь # говорит не о том, что мы имели в виду. Изъятие названо по имени этой # функции, а не выключением `noctx` на проверках целиком: настоящий внешний # вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему # подсуден. - linters: - noctx path: '_test\.go$' text: 'httptest\.NewRequest' formatters: enable: - gofmt