scripts: снесены проверки шага сверки версий Go
- Двадцать сценариев шага были единственной проверкой над проверкой в проекте; запрет CLAUDE.md остался без исключений. - Ссылки на файл сняты в памятке, конвенции линтеров, журнале ревью и статусе ADR о спеке toolchain; норма шага живёт комментариями в самом скрипте.
This commit is contained in:
@@ -198,9 +198,9 @@ task gate # весь набор проверок разом
|
|||||||
механизация покрытия изменённого кода, мутационная сверка оракулов и
|
механизация покрытия изменённого кода, мутационная сверка оракулов и
|
||||||
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
|
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
|
||||||
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
|
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
|
||||||
[tasks/REJECTED.md](tasks/REJECTED.md). Заведённое до решения остаётся:
|
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
|
||||||
двадцать сценариев шага сверки версий Go в `scripts/check_go_version_test.go`
|
сценариев шага сверки версий Go, единственный такой файл в проекте.
|
||||||
работают и удалению не подлежат, новых таких файлов не появляется.
|
Исключений у запрета нет.
|
||||||
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
|
||||||
временном каталоге и убирают за собой.
|
временном каталоге и убирают за собой.
|
||||||
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
- **Дата:** 2026-08-12
|
- **Дата:** 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
|
- **Источник:** [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 владелец решил обратное: инструментарию в
|
- **Статус:** устарело — 2026-08-13 владелец решил обратное: инструментарию в
|
||||||
спеках не место. Capability `toolchain` упразднена, замены у неё нет, норму
|
спеках не место. Capability `toolchain` упразднена, замены у неё нет, а норма
|
||||||
шага держат его проверки в `scripts/check_go_version_test.go`
|
шага осталась комментариями в `scripts/check-go-version.sh`
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
|
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
|
||||||
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
|
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
|
||||||
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
|
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
|
||||||
обязан проверять, и там же записано требование, чтобы проверка была **способна
|
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
||||||
упасть**. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
|
|
||||||
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
|
||||||
исходники.
|
исходники.
|
||||||
|
|
||||||
@@ -38,10 +37,11 @@
|
|||||||
спрашивать уже не нужно;
|
спрашивать уже не нужно;
|
||||||
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
|
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
|
||||||
проверок туда не входят: инструментарий спеками не нормируется, и спека
|
проверок туда не входят: инструментарий спеками не нормируется, и спека
|
||||||
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Норму
|
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
|
||||||
этого шага держат его собственные проверки — двадцать сценариев в
|
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
|
||||||
`scripts/check_go_version_test.go`, и другого дома у неё нет. Второй самодельный
|
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
|
||||||
шаг — `migrations` — не проверен и ими: он прогнан мутацией на трёх исходах
|
тем же решением. Второй самодельный
|
||||||
|
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
|
||||||
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||||||
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
|
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
|
||||||
не числится: проверок над проверками проект не заводит —
|
не числится: проверок над проверками проект не заводит —
|
||||||
@@ -115,7 +115,6 @@
|
|||||||
| Правило | Где механизировано |
|
| Правило | Где механизировано |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
| Проверка судит ответ по готовому ответу (`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` |
|
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||||
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
|
| Одновременный доступ проверен детектором, а не чтением кода | `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`) |
|
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||||
|
|||||||
+3
-3
@@ -461,9 +461,9 @@ API и имя не откатываются обратной правкой по
|
|||||||
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
|
||||||
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
|
||||||
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
согласованно, и парная сверка не увидела бы документ, разошедшийся с
|
||||||
согласованным кодом. Норму шага держат его собственные проверки
|
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
|
||||||
(`scripts/check_go_version_test.go`): спека `toolchain`, бывшая его домом,
|
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
|
||||||
упразднена 2026-08-13 — инструментарий спеками не нормируется.
|
комментариями в самом скрипте.
|
||||||
|
|
||||||
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user