Files
transcriber/scripts/check_go_version_test.go
T
av e1dfe662ea Гейт видит скрипты и Dockerfile, а pre-commit — затронутые файлы
- шаги shell (shellcheck) и dockerfile (hadolint) заведены; два правила hadolint
  подавлены поимённо с причиной — DL3007 до задачи pin-runtime-image-base и
  DL3018 по существу
- lefthook гоняет на затронутых файлах gofmt, golangci-lint по их каталогам,
  shellcheck, hadolint и gitleaks — около секунды; полный набор в pre-commit не
  переносится намеренно
- заведён мутационный тест скрипта сверки версий: 20 сценариев спеки toolchain
  плюс требование сообщения называть все четыре места, независимость исхода от
  установленного go и запрет звать go, docker и сеть
2026-08-13 09:02:05 +03:00

357 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package scripts — проверки скриптов репозитория. Рабочего кода на Go в нём
// нет: пакет существует ради того, чтобы `go test ./...` гонял и shell.
//
// Норма шага сверки версий — openspec/specs/toolchain/spec.md. Каждый её
// сценарий проверяется здесь мутацией: дерево-образец собирается во временном
// каталоге, портится ровно одним способом, и от скрипта требуется объявленный
// исход. Прежде сценарии подтверждались разовыми ручными прогонами — после
// первой правки образца они перестали бы выполняться молча.
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.Command("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")
}