Files
transcriber/docs/conventions/go-linters.md
T
av bb9a67929c docs: правила о контексте и о токене записаны, две находки — в журнал
- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень
  механизированного пополнен девятью правилами и двумя шагами гейта, названы
  остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations
  судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck
  профилактические — предмета в коде нет
- в logging.md и security.md чистка отказа Telegram описана по факту: точка одна
  и лежит на границе клиента, закрыты все пять путей вместе с логгером самой
  библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было
  неверным дважды
- в журнал дефектов записаны две находки: отказ скачивания уносил токен бота
  (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую
  запись в failed (поймано ревью до коммита)
- вопрос темы operations про отмену переформулирован: спрашивать надо не
  «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом
  отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста
  на него не отвечает
- в памятке: словарь кодов новых шагов, требование компилятора C у детектора
  гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
2026-08-13 10:28:31 +03:00

30 KiB
Raw Blame History

Линтеры и механизированные проверки

Конвенция о том, чем машина читает наш код: какие свойства доведены до правила, чем каждое проверяется, когда оно запускается и что осталось человеку. Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется здесь строкой — эта запись его принимает.

Чего здесь нет: как писать тесты. Запись говорит об инструментах и правилах — линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать юнит-тест и какой у него оракул. Это другой предмет, и живёт он в ../review.md: «Типовые узлы» перечисляют свойства, которые тест обязан проверять, и там же записано требование, чтобы проверка была способна упасть. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а потому, что они правила: у них нет ни фикстур, ни поведения — они читают исходники.

Пока язык у проекта один, и запись названа по нему. Появится второй — у него будет своя запись, а лестница и два круга останутся общими.

Устройство ниже переносимо: разделы «Лестница механизации», «Два круга» и «Как заводят новое правило» — не особенность transcriber и переносятся в другой Go-проект как есть. Своё здесь — перечень правил и подавлений.

Границы: где что живёт

Чтобы факт не жил в двух местах:

  • семантика гейта — команда целиком, база диффа, словарь кодов выхода, что красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять — в CLAUDE.md, раздел «Гейт». Здесь это не повторяется: у гейта один дом, и он у памятки, потому что её читают прежде работы;
  • как писать код — соседние записи этой конвенции (README.md — индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют дважды;
  • настройка конвейера ревью, вопросы по темам и журнал дефектов../review.md. Перечень ниже говорит этим вопросам, чего спрашивать уже не нужно;
  • поведение сервиса — нормативные спеки openspec/specs/. У шага сверки версий Go поведение нормировано отдельно, спекой toolchain: это единственная проверка проекта, у которой есть своя 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, раздел «Гейт». Здесь важен принцип: pre-commit не подменяет гейт. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы минуту, и хук отключили бы через день.

Полный набор проверок в pre-commit не переносится сознательно; обратное решение — «гонять всё на каждый коммит» — известно и отклонено по этой же причине.

Механизировано

Проверяется командами из CLAUDE.md; прозой не дублируется и в промптах ревью не пересказывается.

Ошибки и отказы

Правило Где механизировано
Сравнение ошибок через errors.Is и errors.As, не == и не приведением типа .golangci.ymlerrorlint
Ошибка не узнаётся сравнением текста сообщения (strings.Contains(err.Error(), …), err.Error() == …) internal/archrulesTestОшибкаНеУзнаётсяПоТексту
Непроверенное возвращаемое значение ошибки .golangci.ymlerrcheck, включая присваивание в _ (check-blank). Отказ, который решено не проверять, объявляют в exclude-functions поимённо — там сегодня defer Close, os.Remove и send
Непроверенное приведение типа (v := x.(T)) .golangci.ymlerrcheck с check-type-assertions. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и check-blank его не видит
Проверенный отказ не оборачивается в return nil .golangci.ymlnilerr. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа
Отказ выборки из хранилища не теряется (rows.Err()), а сама выборка закрывается .golangci.ymlrowserrcheck, sqlclosecheck. Профилактические: предмета в коде сегодня нет — выборки идут через dbx хранилища, а из database/sql употребляются только sql.NullString и sql.ErrNoRows. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде
Ошибки — только stdlib, без сторонних пакетов .golangci.ymldepguard

Структура и границы

Правило Где механизировано
Ядро (internal/service) не знает ни адаптеров, ни транспортов internal/archrulesTestЯдроНеЗнаетОбАдаптерах, TestЯдроНеЗнаетОТранспортах
Транспорты (controller/http, controller/tg, controller/worker) не знают друг о друге internal/archrulesTestТранспортыНеЗнаютДругОДруге
Адаптер не знает ни ядра, ни транспортов internal/archrulesTestАдаптерыНеЗнаютНиЯдра_НиТранспортов
Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу internal/archrules → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами db:"…" самой структуры, и правило было бы зелёным всегда

Отмена и внешний собеседник

Правило Где механизировано
Запрос и внешний процесс заводятся с контекстом (exec.CommandContext, http.NewRequestWithContext, QueryContext) .golangci.ymlnoctx. Единая точка не нужна: контекст приезжает доводом, а контракты internal/contract несут его первым
Контекст приезжает сверху, а не заводится по месту (context.Background() в середине цепочки) .golangci.ymlcontextcheck
Тело ответа HTTP закрывается .golangci.ymlbodyclose. Отдельно от errcheck: там (io.ReadCloser).Close объявлен исключением, и незакрытое тело от невыясненного Close неотличимо

Время, вывод, конфигурация

Правило Где механизировано
Время читают clock.Now (метка, UTC) и clock.Start (длительность, монотонные часы) — не time.Now по месту .golangci.ymlforbidigo; единая точка — internal/clock
Вывод идёт через slog, а не fmt.Print* и не встроенными print/println .golangci.ymlforbidigo. Не ловит fmt.Fprintln(os.Stdout, …) — первый аргумент по имени функции не судится; остаток прозой в logging.md
Конфигурация приезжает из TOML, а не из окружения .golangci.ymlforbidigo: os.Getenv, os.LookupEnv, os.Environ, os.ExpandEnv — все четыре, иначе запрет обходится соседним именем
Форма вызова slog: только пары «ключ-значение», атрибуты (slog.String и прочие) не употребляются вовсе; msg — константа .golangci.ymlsloglint (kv-only запрещает атрибуты целиком, а не только смешение)

Проверки о самих проверках

Правило Где механизировано
Проверка судит ответ по готовому ответу (Result()), а не по живой карте заголовков обработчика .golangci.ymlforbidigo с 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.ymltestifylint
Одновременный доступ проверен детектором, а не чтением кода Taskfile.yml → шаг tests (go test -race ./...). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в ../review.md. Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты
Строчное подавление называет линтер и причину, а протухшее краснеет .golangci.ymlnolintlint (require-explanation, require-specific, allow-unused: false)

Форма кода и файлов вне Go

Правило Где механизировано
Форматирование исходников .golangci.ymlgofmt; на pre-commit правится на месте
Подозрительные конструкции языка .golangci.ymlgovet, staticcheck, ineffassign, unused
Опечатка в комментарии и в тексте ошибки .golangci.ymlmisspell
Скрипты оболочки 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.ymlgitleaks 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, «Принципы», и правила на это направление нет.

Отдельно названы правила, чей подъём отклонён:

  • 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 (журнал 2026-08-11 про недостижимую норму, 2026-08-13 про обходимый текстовый запрет).

    Мутация ставится по одному нарушению на строку. golangci-lint печатает с одной строки исходника одну находку (умолчание uniq-by-line), и мутация, задевшая сразу два правила, покажет только первое: так молчали sqlclosecheck и rowserrcheck на пробе, где та же строка уже краснела от noctx. Проверять правило пробой, где оно единственное нарушенное.

  5. Записать строкой здесь и удалить прозу из конвенции, если правило её заменило.