diff --git a/.golangci.yml b/.golangci.yml index 8a03249..99fc18c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -20,6 +20,34 @@ linters: # Запреты по месту: чем судят ответ в проверках, чем читают время, откуда # берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже. - 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` включает суждение по типу приёмника, а не по печатному @@ -101,11 +129,24 @@ linters: - 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 @@ -145,6 +186,16 @@ 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: diff --git a/Taskfile.yml b/Taskfile.yml index bd2ac4a..e9e332e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -33,17 +33,94 @@ tasks: echo "gofmt: файлы выше не отформатированы" exit 1 fi - - go test ./... + - task: tests - golangci-lint run - task: shell - task: dockerfile - task: go-version + - task: migrations - task: docs - task: tasks - task: openspec # Последним: единственный шаг, которому нужна сеть, и самый долгий. - task: vulns + tests: + desc: 'Тесты с детектором гонок' + cmds: + # Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну + # очередь, и «результат пишет только держатель захвата» — утверждение о + # одновременном доступе. Детектору нужен CGO и компилятор C; сборка + # приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их + # отсутствие — отказ окружения, код 3, а не отказ проверки. + # Окружение проверяется **после** обычного прогона, а не вместо него: + # отсутствие компилятора отнимает у гейта поиск гонок, но не должно + # отнимать сами тесты. Порядок проверок — сперва компилятор: без него + # совет «включи CGO_ENABLED=1» бесполезен. + - | + if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then + go test ./... || exit 1 + echo "тесты прошли, но гонки не искали: детектору нужен компилятор C" + echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc" + exit 3 + fi + if [ "$(go env CGO_ENABLED)" != "1" ]; then + go test ./... || exit 1 + echo "тесты прошли, но гонки не искали: детектору нужен CGO" + echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate" + exit 3 + fi + go test -race ./... + + migrations: + desc: 'Применённый шаг схемы не переписывается' + cmds: + # Инвариант CLAUDE.md (critical): хранилище считает применённое по имени + # файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый. + # Компилятор этого не держит, и до этого шага не держало ничто. + # + # Судится каталог шагов против базы диффа: у файла шага допустим один + # статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят. + # `migrations.go` под правило не подпадает: строка `Register` у нового шага + # прибавляется именно там, и запрет на него запретил бы заведение шага. + - | + if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then + echo "база диффа не найдена: {{.BASE}}" + echo "задай свою: task migrations BASE=" + exit 3 + fi + # Каталог шагов берётся из docs/.docs.json — там он уже записан ключом + # `migrations` для сверки документов. Свой литерал завёл бы факту второй + # дом: каталог переехал бы, а один из двух стражей молча позеленел. + dir=$(python3 -c 'import json,sys; print(json.load(open("docs/.docs.json"))["migrations"])' 2>/dev/null) || dir="" + if [ -z "$dir" ] || [ ! -d "$dir" ]; then + echo "каталог шагов схемы не найден: ключ migrations в docs/.docs.json → '$dir'" + exit 3 + fi + # Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от + # одного переименования — тот же приём, что у правил `internal/archrules`. + if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then + echo "в $dir нет ни одного файла шага: правило потеряло предмет" + echo "поправь шаблон имени в этом шаге либо ключ migrations в docs/.docs.json" + exit 3 + fi + # Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал» + # ровно настолько, насколько свеж `origin/master`: отставшая ссылка + # читает весь каталог как добавленный, и правило молчит. `HEAD` ловит + # правку закоммиченного шага в рабочем дереве независимо от ссылки. + for base in {{.BASE}} HEAD; do + touched=$(git diff --name-status "$base" -- "$dir" \ + | grep -E '[0-9]{12}_[^/]*\.go$' \ + | grep -vE '^A[[:space:]]' || true) + if [ -n "$touched" ]; then + echo "база $base:" + echo "$touched" + echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага" + echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)" + exit 1 + fi + done + shell: desc: 'shellcheck на скрипты оболочки' cmds: diff --git a/scripts/check_go_version_test.go b/scripts/check_go_version_test.go index 9793007..a046b8e 100644 --- a/scripts/check_go_version_test.go +++ b/scripts/check_go_version_test.go @@ -276,7 +276,9 @@ func treeWithScript(t *testing.T) string { // `env` — добавка к окружению прогона, `args` — аргументы скрипта. func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) { t.Helper() - cmd := exec.Command("sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...) + // Контекст проверки: зависший скрипт умирает вместе с ней, а не переживает + // прогон осиротевшим процессом. + cmd := exec.CommandContext(t.Context(), "sh", append([]string{filepath.Join(root, "scripts", "check-go-version.sh")}, args...)...) cmd.Dir = filepath.Join(root, dir) if len(env) > 0 { cmd.Env = append(os.Environ(), env...)