- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
27 KiB
Линтеры и механизированные проверки
Конвенция о том, чем машина читает наш код: какие свойства доведены до правила, чем каждое проверяется, когда оно запускается и что осталось человеку. Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется здесь строкой — эта запись его принимает.
Чего здесь нет: как писать тесты. Запись говорит об инструментах и правилах — линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать юнит-тест и какой у него оракул. Это другой предмет, и живёт он в ../review.md: «Типовые узлы» перечисляют свойства, которые тест обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а потому, что они правила: у них нет ни фикстур, ни поведения — они читают исходники.
Пока язык у проекта один, и запись названа по нему. Появится второй — у него будет своя запись, а два круга останутся общими.
Устройство ниже переносимо: разделы «Два круга» и «Как заводят новое правило» — не особенность transcriber и переносятся в другой Go-проект как есть. Своё здесь — перечень правил и подавлений.
Границы: где что живёт
Чтобы факт не жил в двух местах:
- семантика гейта — команда целиком, база диффа, словарь кодов выхода, что красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять — в CLAUDE.md, раздел «Гейт». Здесь это не повторяется: у гейта один дом, и он у памятки, потому что её читают прежде работы;
- как писать код — соседние записи этой конвенции (README.md — индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют дважды;
- настройка конвейера ревью, вопросы по темам и журнал дефектов — ../review.md. Перечень ниже говорит этим вопросам, чего спрашивать уже не нужно;
- поведение сервиса — нормативные спеки
openspec/specs/. Шаги набора проверок туда не входят: инструментарий спеками не нормируется, и спекаtoolchain, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего дома у нормы этого шага теперь нет вовсе — она живёт комментариями вscripts/check-go-version.sh, и проверок у шага нет: двадцать сценариев снесены тем же решением. Второй самодельный шаг —migrations— не проверен и не был: он прогнан мутацией на трёх исходах (переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это не числится: проверок над проверками проект не заводит — CLAUDE.md, «Запреты».
Два круга: pre-commit и гейт
Проверки идут двумя кругами, и круг выбирается по цене прогона.
pre-commit (lefthook.yml) |
гейт (task gate) |
|
|---|---|---|
| Когда | на каждый коммит | перед тем как считать задачу сделанной |
| На чём | на затронутых файлах | на всём дереве |
| Сколько идёт | около секунды | десятки секунд |
| Что делает с находкой | gofmt правит и добавляет в коммит, прочее роняет коммит |
роняет прогон |
Перечень работ pre-commit и то, что остаётся только гейту, — в CLAUDE.md, раздел «Гейт». Здесь важен принцип: pre-commit не подменяет гейт. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы минуту, и хук отключили бы через день.
Полный набор проверок в pre-commit не переносится сознательно; обратное решение — «гонять всё на каждый коммит» — известно и отклонено по этой же причине.
Механизировано
Проверяется командами из 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 |
Непроверенное приведение типа (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/worker) не знают друг о друге |
internal/archrules → TestТранспортыНеЗнаютДругОДруге |
| Адаптер не знает ни ядра, ни транспортов | internal/archrules → TestАдаптерыНеЗнаютНиЯдра_НиТранспортов |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | internal/archrules → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (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 |
| Конфигурация приезжает из 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. Что делает шаг без компилятора C и каким кодом краснеет — 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) |
Хранилище, документы, секреты, зависимости
| Правило | Где механизировано |
|---|---|
Применённый шаг схемы не переписывается: у файла шага допустим один статус — A |
Taskfile.yml → шаг migrations. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — BASE и HEAD: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа origin/master, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа migrations секции [docs] в .av-dev.toml, чтобы у факта не было второго дома. Исходы шага и их коды — 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>); - направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
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(). Сегодня таких мест нет.
Как заводят новое правило
Порядок один и тот же, и последние два шага пропускать нельзя.
-
Найти дом. Дом выбирается по тому, чем свойство выражается, а не по тому, что проще включить: настройка готового линтера, запрет по имени, тест-сканер исходников или свой шаг набора проверок.
-
Написать причину рядом. Правило без причины снимают при первом же неудобстве: тот, кто снимает, не знает, что оно ловило.
-
Починить находки, а не подавить. Подавление годится, когда правило говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с причиной. Подавление «пока некогда» — это отложенная работа, и её место в каталоге задач, а не в конфиге.
-
Проверить мутацией. Внести ровно то нарушение, против которого правило написано, и убедиться, что проверка краснеет и называет место. Правило, принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны они в ../review.md (журнал 2026-08-11 про недостижимую норму, 2026-08-13 про обходимый текстовый запрет).
Мутация обязана собираться. Правка, снявшая последнее употребление импорта, роняет сборку, а не проверку: вывод при этом похож на отказ, и мутацию легко засчитать сработавшей. Прежде чем верить красному, убедись, что красное — от проверки.
Мутация ставится по одному нарушению на строку.
golangci-lintпечатает с одной строки исходника одну находку (умолчаниеuniq-by-line), и мутация, задевшая сразу два правила, покажет только первое: так молчалиsqlclosecheckиrowserrcheckна пробе, где та же строка уже краснела отnoctx. Проверять правило пробой, где оно единственное нарушенное. -
Записать строкой здесь и удалить прозу из конвенции, если правило её заменило.