// 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") }