diff --git a/CLAUDE.md b/CLAUDE.md index 2fc89ac..b1d82b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,9 +198,9 @@ task gate # весь набор проверок разом механизация покрытия изменённого кода, мутационная сверка оракулов и требование мутировать тест, чтобы убедиться в его способности упасть. Решение владельца 2026-08-13; им закрыты четыре задачи — причины и даты в - [tasks/REJECTED.md](tasks/REJECTED.md). Заведённое до решения остаётся: - двадцать сценариев шага сверки версий Go в `scripts/check_go_version_test.go` - работают и удалению не подлежат, новых таких файлов не появляется. + [tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать + сценариев шага сверки версий Go, единственный такой файл в проекте. + Исключений у запрета нет. - **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во временном каталоге и убирают за собой. - **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не diff --git a/docs/adr/ADR-2026-08-12-spec-norms-build-toolchain.md b/docs/adr/ADR-2026-08-12-spec-norms-build-toolchain.md index d5ca6b2..905e67a 100644 --- a/docs/adr/ADR-2026-08-12-spec-norms-build-toolchain.md +++ b/docs/adr/ADR-2026-08-12-spec-norms-build-toolchain.md @@ -3,8 +3,8 @@ - **Дата:** 2026-08-12 - **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2 - **Статус:** устарело — 2026-08-13 владелец решил обратное: инструментарию в - спеках не место. Capability `toolchain` упразднена, замены у неё нет, норму - шага держат его проверки в `scripts/check_go_version_test.go` + спеках не место. Capability `toolchain` упразднена, замены у неё нет, а норма + шага осталась комментариями в `scripts/check-go-version.sh` ## Решение diff --git a/docs/conventions/go-linters.md b/docs/conventions/go-linters.md index 6ac3ba1..936ea52 100644 --- a/docs/conventions/go-linters.md +++ b/docs/conventions/go-linters.md @@ -9,8 +9,7 @@ линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать юнит-тест и какой у него оракул. Это другой предмет, и живёт он в [../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест -обязан проверять, и там же записано требование, чтобы проверка была **способна -упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а +обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а потому, что они правила: у них нет ни фикстур, ни поведения — они читают исходники. @@ -38,10 +37,11 @@ спрашивать уже не нужно; - **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора проверок туда не входят: инструментарий спеками не нормируется, и спека - `toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Норму - этого шага держат его собственные проверки — двадцать сценариев в - `scripts/check_go_version_test.go`, и другого дома у неё нет. Второй самодельный - шаг — `migrations` — не проверен и ими: он прогнан мутацией на трёх исходах + `toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего + дома у нормы этого шага теперь нет вовсе — она живёт комментариями в + `scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены + тем же решением. Второй самодельный + шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах (переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это не числится: проверок над проверками проект не заводит — @@ -115,7 +115,6 @@ | Правило | Где механизировано | | --- | --- | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` | -| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев шага плюс два его свойства: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть | | Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `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`) | diff --git a/docs/review.md b/docs/review.md index 2a71a9f..ad5ecf3 100644 --- a/docs/review.md +++ b/docs/review.md @@ -461,9 +461,9 @@ API и имя не откатываются обратной правкой по (`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали согласованно, и парная сверка не увидела бы документ, разошедшийся с -согласованным кодом. Норму шага держат его собственные проверки -(`scripts/check_go_version_test.go`): спека `toolchain`, бывшая его домом, -упразднена 2026-08-13 — инструментарий спеками не нормируется. +согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain` +упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт +комментариями в самом скрипте. ## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью] diff --git a/scripts/check_go_version_test.go b/scripts/check_go_version_test.go deleted file mode 100644 index c4ffad8..0000000 --- a/scripts/check_go_version_test.go +++ /dev/null @@ -1,359 +0,0 @@ -// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём -// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell. -// -// Норма шага сверки версий живёт здесь и в комментариях самого скрипта: спека -// toolchain, бывшая её домом, упразднена 2026-08-13 — инструментарий спеками не -// нормируется. Каждый сценарий проверяется мутацией: дерево-образец собирается -// во временном каталоге, портится ровно одним способом, и от скрипта требуется -// объявленный исход. Прежде сценарии подтверждались разовыми ручными прогонами — -// после первой правки образца они перестали бы выполняться молча. -package scripts - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "testing" -) - -// Дерево-образец: все четыре места называют одну версию. -// -// `CLAUDE.md` держит второе число **за** разделом стека намеренно: так выглядит -// правдивая строка о закрытом долге, и норма велит её не читать. -var fixture = map[string]string{ - "go.mod": "module example\n\ngo 1.26.0\n", - "Dockerfile": "FROM docker.io/library/golang:1.26-alpine AS builder\n" + - "RUN true\n\n" + - "FROM docker.io/library/alpine:3.22\n", - "CLAUDE.md": "# CLAUDE.md\n\n" + - "## Стек\n\nGo 1.26, встроенная PocketBase.\n\n" + - "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n", - "README.md": "# transcriber\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n", -} - -// allPlaces — все четыре места и число каждого: этого требует норма от -// сообщения о расхождении. Числа два, потому что разошедшееся место называет -// своё. -var allPlaces = []string{"go.mod", "Dockerfile", "CLAUDE.md", "README.md", "1.26", "1.25"} - -const ( - exitOK = 0 - exitDrift = 1 - exitUsage = 2 - exitEnviron = 3 -) - -func TestСверкаВерсийПоСценариямНормы(t *testing.T) { - cases := []struct { - name string - // mutate портит дерево-образец; nil — дерево не портится. - mutate func(t *testing.T, root string) - // args — аргументы скрипта. - args []string - // dir — рабочий каталог прогона относительно корня дерева. - dir string - want int - // says — что обязано прозвучать в сообщении. - says string - // saysAll — что обязано прозвучать всё разом. Норма требует от сообщения - // о расхождении **все четыре места и число каждого**: в дефекте - // 2026-08-12 три места из четырёх говорили одно и то же, и неверными - // были именно они — по сообщению о паре человек чинит не то место. - saysAll []string - // saysNot — чего в сообщении быть не должно. - saysNot string - }{ - {name: "все четыре места названы одинаково", want: exitOK}, - { - name: "патч сборочного образа отличается законно", - mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26.5-alpine"), - want: exitOK, - }, - { - name: "база сборочного образа сменилась", - mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.26-bookworm"), - want: exitOK, - }, - { - name: "сборочный образ собран в два слоя", - mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.26-alpine AS tools"), - want: exitOK, - }, - { - name: "слои сборочного образа разошлись между собой", - mutate: replace("Dockerfile", "RUN true", "FROM docker.io/library/golang:1.25-alpine AS tools"), - want: exitDrift, - says: "Dockerfile", - }, - { - name: "разошёлся сборочный образ", - mutate: replace("Dockerfile", "golang:1.26-alpine", "golang:1.25-alpine"), - want: exitDrift, - says: "разошлись", - saysAll: allPlaces, - }, - { - name: "разошлось требование модуля", - mutate: replace("go.mod", "go 1.26.0", "go 1.25.0"), - want: exitDrift, - says: "разошлись", - saysAll: allPlaces, - }, - { - name: "разошлась памятка", - mutate: replace("CLAUDE.md", "Go 1.26, встроенная", "Go 1.25, встроенная"), - want: exitDrift, - says: "разошлись", - saysAll: allPlaces, - }, - { - name: "разошёлся README", - mutate: replace("README.md", "Go 1.26 и ffmpeg", "Go 1.25 и ffmpeg"), - want: exitDrift, - says: "разошлись", - saysAll: allPlaces, - }, - { - name: "раздел стека называет версию дважды", - mutate: replace("CLAUDE.md", "встроенная PocketBase.", "встроенная PocketBase, всё та же Go 1.26."), - want: exitDrift, - says: "больше одного раза", - }, - { - name: "число за пределами раздела стека не читается", - mutate: replace("CLAUDE.md", "Go 1.24 — долг закрыт.", "Go 1.24 и Go 1.23 — долги закрыты."), - want: exitOK, - }, - { - name: "заголовок раздела встретился внутри блока кода", - mutate: replace("README.md", "## Технологии\n\nGo 1.26 и ffmpeg.\n", - "## Пример\n\n```md\n## Технологии\n\nGo 1.19 из примера.\n```\n\n## Технологии\n\nGo 1.26 и ffmpeg.\n"), - want: exitOK, - }, - { - name: "раздел стека закрыт заголовком верхнего уровня", - mutate: replace("CLAUDE.md", "## Гейт\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n", - "# Приложение\n\nПрежде проект собирался на Go 1.24 — долг закрыт.\n"), - want: exitOK, - }, - { - name: "раздела стека нет вовсе", - mutate: replace("CLAUDE.md", "## Стек", "## Инструменты"), - want: exitDrift, - says: "нет раздела", - }, - { - name: "версия не названа там, где должна быть", - mutate: replace("README.md", "Go 1.26 и ffmpeg.", "ffmpeg и всё остальное."), - want: exitDrift, - says: "не называет версию", - }, - { - name: "модуль объявляет версию пятым местом", - mutate: replace("go.mod", "go 1.26.0", "go 1.26.0\n\ntoolchain go1.27.0"), - want: exitDrift, - says: "toolchain", - }, - { - name: "места нет вовсе", - mutate: remove("README.md"), - want: exitEnviron, - says: "нет файла", - }, - { - name: "место существует, но не читается", - mutate: unreadable("README.md"), - want: exitEnviron, - says: "нечитаем", - saysNot: "не называет версию", - }, - { - name: "шаг запущен не из корня проекта", - dir: "scripts", - want: exitOK, - }, - { - name: "шагу переданы аргументы", - args: []string{"--base", "origin/master"}, - want: exitUsage, - says: "Использование", - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - root := treeWithScript(t) - if c.mutate != nil { - c.mutate(t, root) - } - code, out := runScript(t, root, c.dir, c.args) - if code != c.want { - t.Errorf("код возврата %d, ожидался %d\nвывод:\n%s", code, c.want, out) - } - if c.says != "" && !strings.Contains(out, c.says) { - t.Errorf("в сообщении нет %q\nвывод:\n%s", c.says, out) - } - for _, want := range c.saysAll { - if !strings.Contains(out, want) { - t.Errorf("сообщение не называет %q\nвывод:\n%s", want, out) - } - } - if c.saysNot != "" && strings.Contains(out, c.saysNot) { - t.Errorf("в сообщении есть лишнее %q\nвывод:\n%s", c.saysNot, out) - } - }) - } -} - -// Норма требует, чтобы исход был функцией коммита, а не машины: скрипт не -// спрашивает установленный инструмент. Проверяется это прогоном без `go` в -// `PATH` — исход обязан не измениться. -func TestИсходНеЗависитОтУстановленногоGo(t *testing.T) { - root := treeWithScript(t) - - withGo, outWith := runScript(t, root, "", nil) - if withGo != exitOK { - t.Fatalf("дерево-образец обязано сходиться, а код %d:\n%s", withGo, outWith) - } - - goBin, err := exec.LookPath("go") - if err != nil { - t.Skip("go не найден в PATH — проверять нечего") - } - var kept []string - for _, dir := range filepath.SplitList(os.Getenv("PATH")) { - if dir != filepath.Dir(goBin) { - kept = append(kept, dir) - } - } - withoutGo, outWithout := runScript(t, root, "", nil, "PATH="+strings.Join(kept, string(os.PathListSeparator))) - if withoutGo != withGo { - t.Errorf("без go в PATH код %d, с ним %d\nвывод:\n%s", withoutGo, withGo, outWithout) - } -} - -// Скрипт не зовёт ни `go`, ни `docker`, ни сеть — это читается из его текста, и -// правило держит именно текст: прогон без сети в наборе проверок недоступен. -func TestСкриптНеЗоветНиGoНиDocker(t *testing.T) { - body, err := os.ReadFile("check-go-version.sh") - if err != nil { - t.Fatalf("читаю скрипт: %v", err) - } - // Границы слова обязательны: имя `read_dockerfile` и переменная `dockerfile` - // законны — читается файл, а не зовётся демон. - code := withoutComments(string(body)) - for _, forbidden := range []string{`go\s+version`, `go\s+env`, `GOTOOLCHAIN`, `\bdocker\b`, `\bcurl\b`, `\bwget\b`} { - if regexp.MustCompile(forbidden).FindString(code) != "" { - t.Errorf("скрипт зовёт %s вне комментария: исход перестаёт быть функцией коммита", forbidden) - } - } -} - -// --- Помощники -------------------------------------------------------------- - -// treeWithScript собирает дерево-образец и кладёт в него сам скрипт: корень он -// считает от своего расположения, поэтому проверяется копия внутри дерева. -func treeWithScript(t *testing.T) string { - t.Helper() - root := t.TempDir() - for name, body := range fixture { - write(t, filepath.Join(root, name), body, 0o644) - } - script, err := os.ReadFile("check-go-version.sh") - if err != nil { - t.Fatalf("читаю скрипт: %v", err) - } - if err := os.Mkdir(filepath.Join(root, "scripts"), 0o755); err != nil { - t.Fatalf("завожу каталог scripts: %v", err) - } - write(t, filepath.Join(root, "scripts", "check-go-version.sh"), string(script), 0o755) - return root -} - -// runScript гоняет скрипт и отдаёт код возврата с объединённым выводом. -// `env` — добавка к окружению прогона, `args` — аргументы скрипта. -func runScript(t *testing.T, root, dir string, args []string, env ...string) (int, string) { - t.Helper() - // Контекст проверки: зависший скрипт умирает вместе с ней, а не переживает - // прогон осиротевшим процессом. - 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...) - } - out, err := cmd.CombinedOutput() - code := 0 - if err != nil { - var exit *exec.ExitError - if !errors.As(err, &exit) { - t.Fatalf("прогон скрипта: %v", err) - } - code = exit.ExitCode() - } - return code, string(out) -} - -func replace(file, old, new string) func(*testing.T, string) { - return func(t *testing.T, root string) { - t.Helper() - path := filepath.Join(root, file) - body, err := os.ReadFile(path) - if err != nil { - t.Fatalf("читаю %s: %v", file, err) - } - if !strings.Contains(string(body), old) { - t.Fatalf("в образце %s нет %q: мутация потеряла предмет", file, old) - } - write(t, path, strings.Replace(string(body), old, new, 1), 0o644) - } -} - -func remove(file string) func(*testing.T, string) { - return func(t *testing.T, root string) { - t.Helper() - if err := os.Remove(filepath.Join(root, file)); err != nil { - t.Fatalf("убираю %s: %v", file, err) - } - } -} - -func unreadable(file string) func(*testing.T, string) { - return func(t *testing.T, root string) { - t.Helper() - path := filepath.Join(root, file) - if err := os.Chmod(path, 0o000); err != nil { - t.Fatalf("снимаю права с %s: %v", file, err) - } - // Права возвращаются, иначе уборка временного каталога отказала бы. - t.Cleanup(func() { - if err := os.Chmod(path, 0o644); err != nil { - t.Errorf("возвращаю права %s: %v", file, err) - } - }) - if body, err := os.ReadFile(path); err == nil { - t.Skipf("файл читается и без прав (%d байт) — прогон под root?", len(body)) - } - } -} - -func write(t *testing.T, path, body string, perm os.FileMode) { - t.Helper() - if err := os.WriteFile(path, []byte(body), perm); err != nil { - t.Fatalf("пишу %s: %v", path, err) - } -} - -// withoutComments снимает строки-комментарии: слово в объяснении вызовом не -// является, а объяснения в этом скрипте длиннее самого кода. -func withoutComments(body string) string { - var kept []string - for line := range strings.SplitSeq(body, "\n") { - if !strings.HasPrefix(strings.TrimSpace(line), "#") { - kept = append(kept, line) - } - } - return strings.Join(kept, "\n") -}