diff --git a/CLAUDE.md b/CLAUDE.md index ca46a8f..5ff07d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр ## Стек -Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и +Go 1.26 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. @@ -102,15 +102,28 @@ task gate # весь набор проверок разом `origin/master`; переопределяется `task gate BASE=`. - **Где логи шагов:** вывод команды, отдельного файла нет. - **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У - `docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий: - 0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта, - каталог не найден), 4 внутренний сбой. + `docs.py check`, `tasks.py check`, `openspec.py check` и + `scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф, + 2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не + найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: + четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. - **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, - неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов, + неотформатированный файл, находка `golangci-lint`, расхождение объявленных + версий Go, дрейф раскладки документов, дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с именем недостающего плагина, а не пропускается молча. - **Чего в гейте намеренно нет и кто тогда обязан это гонять:** + - **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена + стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за + которого образ перестаёт собираться, но собираемости не проверяет. Собрать + образ по-прежнему может только человек — `task image`, и на подъёме версии + это обязательно; + - `shellcheck` — shell-скрипт в гейте один, `scripts/check-go-version.sh`; три + соседних шага это Python в плагинах, и линтер оболочки к ним неприменим. + Линтер не заведён, и цена ему одна строка шага плюс одно подавление ложного + `SC1007` на идиому `CDPATH= cd`. Его не проверяет ничто, а это единственный + исполняемый файл проекта, которого не видят ни `go vet`, ни `golangci-lint`; - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс коммита. Полную историю никто не проверяет; - согласованность документов между собой и с кодом — её судят агенты, зовёт diff --git a/Dockerfile b/Dockerfile index 8536a30..f15e5ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM docker.io/library/golang:1.25-alpine AS build-env +FROM docker.io/library/golang:1.26-alpine AS build-env # Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite, # и CGO больше не требуется. diff --git a/README.md b/README.md index 1b9b259..473da89 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Технологии +- **Язык**: Go 1.26, CGO не нужен - **Веб-фреймворк**: gin-gonic/gin - **Telegram**: go-telegram-bot-api - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) diff --git a/Taskfile.yml b/Taskfile.yml index bdde9dd..eabac4f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -29,10 +29,25 @@ tasks: fi - go test ./... - golangci-lint run + - task: go-version - task: docs - task: tasks - task: openspec + go-version: + desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md' + cmds: + # Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит + # сломанное дерево, а не непоставленный плагин, и переопределять путь + # нечем и незачем. + - | + py=scripts/check-go-version.sh + if [ ! -f "$py" ]; then + echo "$py не найден: дерево репозитория неполно" + exit 1 + fi + sh "$py" + docs: desc: 'Раскладка docs/ против канона' cmds: 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 new file mode 100644 index 0000000..0d36caf --- /dev/null +++ b/docs/adr/ADR-2026-08-12-spec-norms-build-toolchain.md @@ -0,0 +1,62 @@ +# ADR-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 + +## Решение + +Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не +поведение сервиса** для его потребителей, а поведение инструмента, которым сервис +собирают. Потребитель у неё другой: тот, кто собирает. + +Требование о согласованности объявленной версии Go живёт нормой в +[openspec/specs/toolchain/spec.md](../../openspec/specs/toolchain/spec.md), а не +прозой в памятке. + +## Почему + +Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что +сервис делает для своих потребителей, а преамбула `architecture.md` прямо +говорила «поведение системы здесь не описывается — нормативно оно живёт в +`openspec/specs/`». Согласованность версий сборки под это определение не +подходит, и натяжение признано прямо в источнике: + +> Признаём натяжение: три существующие capability описывают поведение сервиса для +> его потребителей, а `toolchain` описывает поведение инструмента разработки. +> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml` +> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно +> как домен он здесь и назван. + +Очевидные пути отвергнуты оба: + +> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и +> захват задачи — поведение работающего сервиса. Версия сборщика с ним не +> меняется вместе. +> +> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое +> требование — «расхождение роняет набор проверок», — и требование без дома +> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже +> один раз жило в трёх документах и во всех трёх было неверным. + +Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12 +случился именно потому, что утверждение о версии сборки жило только прозой, в +трёх местах сразу, и никто не отвечал за его истинность. + +## Последствия + +- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что + проверено, а что оставлено человеку. Три требования, двадцать два сценария. +- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит + вторую спеку о том же. +- `−` определение capability в проекте стало шире, чем «поведение сервиса», и + граница теперь проходит по слову «домен». Следующее пограничное решение будет + ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы. +- `−` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх + плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе, + только строка в памятке; у четвёртого есть спека. Либо дома появятся у + остальных, либо асимметрия останется навсегда. +- `−` имя `toolchain` выбрано в том числе из-за настройки среды разработчика: + первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким + именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под + ограничение инструмента, а не под предмет, — слабое основание, и при следующем + пересмотре его стоит перепроверить. diff --git a/docs/adr/ADR-2026-08-12-version-read-from-repo-not-from-tool.md b/docs/adr/ADR-2026-08-12-version-read-from-repo-not-from-tool.md new file mode 100644 index 0000000..3cb9df8 --- /dev/null +++ b/docs/adr/ADR-2026-08-12-version-read-from-repo-not-from-tool.md @@ -0,0 +1,60 @@ +# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента + +- **Дата:** 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`, Решения 1 и 6 + +## Решение + +Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком +виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива +`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг. + +Дословно из источника: + +> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче +> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой +> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN` +> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети» +> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а +> не от коммита. + +## Почему + +Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект, +ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25, +сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта +с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на +хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что +записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя: +зелёный там, где стоит нужная версия, и другой ответ на другой машине. + +Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное +решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и +сверять стало бы нечего. Отвергнута намеренно: + +> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и +> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку +> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке. +> Дороже и менее предсказуемо, чем строка сравнения. + +Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый +перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных +числа сойдутся, а собирать будет пятое. + +## Последствия + +- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет + `go`, шаг даёт тот же код выхода и тот же вывод. +- `+` требование «без сети» выполняется по построению, а не обещанием: под + `strace` шаг не делает ни одного сетевого вызова. +- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной + причиной. +- `−` разбор держится на регулярных выражениях `sed`/`awk` вместо готового + разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка + образца ломает смежный случай беззвучно. +- `−` запрет `toolchain` придётся снять или пересмотреть, если зависимость + однажды потребует версию выше той, что стоит у нас. Тогда эта запись + пересматривается, а не обходится. +- `−` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него + нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё + сошлось. Остаток объявлен и уехал отдельной задачей. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6245b15..c94cfac 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,8 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | | +| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | | | 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | | | 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | | | 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index c49c0be..78d36d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,9 @@ [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из этого ещё не решено — в разделе «Открытые вопросы». -Заведены три capability: +Заведены четыре capability. Три первые нормируют **поведение сервиса** для его +потребителей; четвёртая — исключение из первого абзаца: она нормирует не сервис, а +инструмент, которым его собирают, и потребитель у неё другой — тот, кто собирает. - [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его нормируют проверки, написанные задачей `http-handler-tests-never-green` @@ -20,6 +22,10 @@ долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` + 2026-08-12; +- [toolchain](../openspec/specs/toolchain/spec.md) — каким инструментом и какой + его версии собирается сервис: одно число версии Go во всех местах, где она + названа, и шаг гейта, который это сверяет. Задача `go-1-26-upgrade` 2026-08-12. Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в diff --git a/docs/conventions/README.md b/docs/conventions/README.md index db79669..1565bcf 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -60,6 +60,7 @@ htmx, а здесь решено делать SPA — и перенесённы | Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` | | Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` | | Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` | +| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) | Не названное здесь место механизации означает, что проход по конвенциям будет добросовестно проверять уже проверенное. diff --git a/docs/review.md b/docs/review.md index a8b9418..b3489ed 100644 --- a/docs/review.md +++ b/docs/review.md @@ -219,6 +219,12 @@ API и имя не откатываются обратной правкой по директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем ревью. +**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate` +(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`, +`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали +согласованно, и парная сверка не увидела бы документ, разошедшийся с +согласованным кодом. Норма — capability `toolchain`. + ## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью] - **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац diff --git a/go.mod b/go.mod index 60681d3..70efbd2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.vakhrushev.me/av/transcriber -go 1.25.0 +go 1.26.0 require ( github.com/BurntSushi/toml v1.5.0 @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.3 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 + github.com/aws/smithy-go v1.27.7 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 @@ -35,7 +36,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect - github.com/aws/smithy-go v1.27.7 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index e5922fb..2eea2a3 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E= github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M= github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58= -github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= -github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE= github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/openspec/changes/archive/2026-08-12-go-1-26-upgrade/.openspec.yaml b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/.openspec.yaml new file mode 100644 index 0000000..5081c98 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-12 diff --git a/openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md new file mode 100644 index 0000000..a7b96da --- /dev/null +++ b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md @@ -0,0 +1,230 @@ +## Context + +Версия Go названа в проекте четырежды, и сегодня четыре места расходятся: +`go.mod` требует `go 1.25.0`, `Dockerfile` собирает на `golang:1.25-alpine`, +`CLAUDE.md` обещает «Go 1.25», а `README.md` не называет версию вовсе. На машине +разработки стоит `go1.26.5`. + +Число 1.25 никем не назначалось: `go mod tidy` поднял требование модуля, +следуя за PocketBase, а образ подтянули следом. Ровно этот же механизм 2026-08-12 +породил дефект — требование модуля уехало на 1.25, `Dockerfile` остался на +`golang:1.24-alpine` с `GOTOOLCHAIN=local`, и образ перестал собираться. Восемь +шагов набора проверок и шесть проходов ревью показали зелёное: `go build ./...` +идёт на хостовом Go, а образ не собирает ни один шаг. Случай записан в +`docs/review.md` за 2026-08-12 и там же назван способ починки — сравнение строк +вместо сборки образа. + +Ограничения, в которых работаем: набор проверок обязан оставаться дешёвым и +работать без сети и без docker; выкладку это изменение не запускает; сборку +образа в набор проверок не заводим — отказ записан. + +## Goals / Non-Goals + +**Goals:** + +- одно число версии Go во всех четырёх местах; +- шаг набора проверок, который краснеет на расхождении и называет оба числа; +- шаг стоит доли секунды и не зависит ни от docker, ни от сети. + +**Non-Goals:** + +- сборка образа шагом набора проверок — дорого, отказ записан в + `docs/review.md`; +- проверка того, что объявленная версия вообще существует в реестре образов, — + это требует сети; +- сверка версий прочих инструментов (`golangci-lint`, `task`, `ffmpeg`) — их + расхождение так не ломает, и заводить перечень впрок незачем; +- `shellcheck` шагом набора проверок. Замер: shell-скриптов в гейте один, три + соседних шага — Python в плагинах, линтер к ним неприменим; цена шага — одна + строка плюс подавление ложного `SC1007` на идиому `CDPATH= cd`. Отказ всё равно + осознанный, но цену называем настоящую, а не «их четыре»; +- автоматическая правка разошедшихся мест — шаг набора проверок судит, а не + чинит. + +## Decisions + +### Решение 1: версия 1.26, а число выбирает человек + +Берём 1.26 — она стоит на машине разработки (`go1.26.5`), образ +`golang:1.26-alpine` в реестре есть, последний релиз тоже `go1.26.5`, а +`CGO_ENABLED=0 go build ./...` на ней уже проходит. Требование PocketBase v0.39.10 +(`go 1.25.0`) она выполняет. + +В `go.mod` пишем `go 1.26.0`, а не `1.26.5`: требование модуля — это нижняя +граница, и привязывать её к патчу значит без нужды отсекать сборку на более +раннем патче той же минорной версии. + +**Число называет человек, и нормой это не записано намеренно.** `go mod tidy` +поднимает требование модуля сам, следуя за зависимостью, и подъём, никем не +назначенный, дал сегодняшнее расхождение. Но «выбрал человек» ненаблюдаемо: +директива, поднятая инструментом, и директива, назначенная решением, выглядят +одинаково, а норма, которую нечем уронить, расходится с кодом молча. Поэтому +здесь мотив, а в спеке — то, что проверяется: сборка и тесты на объявленном +числе до мерджа. + +**Отвергнуто: остаться на 1.25 и завести только сверку.** Сверка — половина +задачи, и она бы прижилась; но тогда сегодняшнее число остаётся тем, которое +никто не назначал, и первый же `go mod tidy` следующей зависимости повторит +подъём вслепую. Задача собрана из двух половин именно поэтому. + +**Отвергнуто: `toolchain` в `go.mod` вместо подъёма `go`.** Директива +`toolchain` заставила бы Go скачивать нужный тулчейн сам, и расхождение с +образом перестало бы ломать сборку. Но она же превращает сборку образа в +сетевую операцию, а сборочный слой качает тулчейн при каждой сборке. Дороже и +менее предсказуемо, чем строка сравнения. + +### Решение 2: новая capability `toolchain` + +Дельта-спека ложится в новую capability `toolchain` — «каким инструментом и какой +его версии собирается сервис, и что об этом проверяется до выкладки». + +**Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и +захват задачи — поведение работающего сервиса. Версия сборщика с ним не меняется +вместе, а правило гранулярности в `openspec/config.yaml` именно про это: «дробить, +когда в одной спеке смешиваются разные заботы». + +**Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое +требование — «расхождение роняет набор проверок», — и требование без дома +проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже +один раз жило в трёх документах и во всех трёх было неверным. + +**Отвергнуто имя `build`.** Первая редакция называла capability `build`, и на +разметке выяснилось, что читать её нельзя: настройка среды разработчика +запрещает чтение любого каталога с этим именем. Спека, недоступная проходам +ревью, не проверяется ни одним из них, а после архивации осталась бы слепым +пятном насовсем. Имя `toolchain` точнее и по существу: предмет здесь — +инструмент сборки и его версия, а не сборка как процесс. + +Признаём натяжение: три существующие capability описывают поведение сервиса для +его потребителей, а `toolchain` описывает поведение инструмента разработки. +Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml` +говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно +как домен он здесь и назван. Если capability так и останется с одним +требованием, дешевле будет переименовать её, чем расщепить +(`RENAMED Requirements`). + +### Решение 3: шаг сверяет все четыре места, а не два + +Минимум по критерию приёмки — `go.mod` против `Dockerfile`. Берём шире: плюс +`CLAUDE.md` и `README.md`. + +Причина прямо из дефекта 2026-08-12: **три места из четырёх говорили одно и то +же, и неверными были именно они.** Пару `go.mod`↔`Dockerfile` парная сверка +тогда поймала бы — та пара как раз разошлась. Чего она не ловит, так это +документа, разошедшегося с **согласованным** кодом: сойдись тогда `go.mod` с +образом на 1.24, и памятка с README продолжали бы врать молча, а гейт оставался +бы зелёным. Сегодня проект ровно в этом состоянии наполовину: `README.md` не +называет версию вовсе, а `CLAUDE.md` проверяется только тем, что кто-то её +прочтёт. + +Цена: строку о версии придётся держать в форме, которую находит машина. Это же и +польза — документ, чья строка перестала находиться, краснеет вместо того, чтобы +молча протухнуть. + +**Отвергнуто: сверять только `go.mod` и `Dockerfile`.** Дешевле на три строки +скрипта и не ловит половину прошлого дефекта. + +**Отвергнуто: сверять ещё и `config.dist.toml`, `docker/entrypoint.sh` и +Ansible-роль в `pet-project-server`.** Версии Go там нет; чужой репозиторий этому +набору проверок недоступен. + +### Решение 4: отдельный скрипт в репозитории, а не строка в `Taskfile.yml` + +Шаг живёт файлом `scripts/check-go-version.sh`, а `Taskfile.yml` его зовёт. + +Сверка четырёх мест — это четыре разных способа достать число (директива +модуля, тег образа, проза памятки, проза README), сравнение и внятное сообщение +со всеми четырьмя. В `Taskfile.yml` это легло бы двадцатью строками shell внутри +YAML, где их не читает ни редактор, ни `shellcheck`, а кавычки экранируются +дважды. Соседние шаги (`docs`, `tasks`, `openspec`) уже зовут скрипты, и эта +форма для набора проверок родная. + +Скрипт лежит в репозитории, а не в плагине: он про этот проект, а не про метод +работы. + +Оговорка о выигрыше: `shellcheck` шагом набора проверок этим изменением **не** +заводится, и обоснование выше стоит на том, что файл хотя бы **можно** проверить +и прочитать глазами, а не на том, что его кто-то проверяет машиной. Заводить +линтер оболочки — отдельная работа, и она уезжает урожаем. + +**Отвергнуто: строка shell прямо в `Taskfile.yml`.** Дешевле на один файл, +дороже при первом же изменении: правка регулярного выражения в YAML-скаляре +ошибается молча. + +**Отвергнуто: написать проверку на Go отдельной командой.** Тогда она попадает в +`go build ./...` и `go vet ./...`, а вместе с ней — разбор `Dockerfile` в коде +сервиса. Проверка о проекте не должна ехать в бинарник сервиса. + +### Решение 5: форма строки, которую ищет машина + +| Место | Что ищем | Правило множественности | +| --- | --- | --- | +| `go.mod` | единственная строка, начинающаяся с `go ` — первые два числа | директива `toolchain` запрещена: она пятое место | +| `Dockerfile` | тег `golang:<мажор>.<минор>[.<патч>][-<база>]`, берём первые два числа | все вхождения `FROM golang:` обязаны давать одно число | +| `CLAUDE.md` | образец `Go <мажор>.<минор>` в разделе `## Стек` | ровно одно вхождение в разделе; вне раздела число не читается | +| `README.md` | образец `Go <мажор>.<минор>` в разделе `## Технологии` | ровно одно вхождение в разделе; вне раздела число не читается | + +Патч и база образа из тега отбрасываются: спека объявила их свободными, и +образец обязан это допускать — иначе `golang:1.26.5-alpine` уронил бы набор +проверок на дереве, которое та же спека называет верным. + +Правило множественности заведено не впрок: реализация, молча берущая первое +совпадение, судила бы по обновлённой строке и не видела протухшей соседней. + +**Рамка у него — раздел, а не файл, и это правка по находке ревью.** Первая +редакция считала вхождения по всему файлу, и на памятке это давало +гарантированный ложный красный: она по устройству ведёт историю закрытых долгов +(«Два прежних долга закрыты и здесь названы»), и первая же правдивая строка о +прошлой версии уронила бы шаг — сообщением, которое толкает чинить не шаг, а +исторический документ. Тем же ловился бы любой пример команды в README. Решение +человека на чекпоинте: считать по разделу стека, за его пределами число не +читать. + +Граница раздела при этом определена явно — следующий заголовок того же или более +высокого уровня, и заголовок внутри блока кода за заголовок не считается. +Неопределённая граница давала бы **ложное зелёное**: пример в чужом разделе +открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда +его читать не велено. + +Число не нашлось — это отказ с названным местом, а не «нечего сравнивать»: +пропавшая строка иначе выглядела бы как совпадение. + +### Решение 6: шаг судит по репозиторию, а не по машине + +Число берётся чтением файлов. `go` шаг не зовёт вовсе — ни `go mod edit -json`, +ни `go list -m`, ни `go env`. + +Способ это не самый удобный: разбор директивы через `go mod edit -json` короче и +надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой +`GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN` `go` +вправе полезть в сеть за нужной версией — то есть требование «без сети» +перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а не +от коммита, — ровно та подмена, которая держала дефект 2026-08-12 невидимым: +`go build ./...` шёл на хостовом Go и потому был зелёным, пока образ не +собирался. + +Отсюда же и словарь кодов выхода: 0 сошлось, 1 расхождение, 2 ошибка +употребления, 3 окружение. Свой словарь заводить нельзя — раздел «Гейт» в +`CLAUDE.md` объявляет его общим для проверочных шагов, и четвёртый шаг с +собственной семантикой сделал бы это утверждение неверным. + +Оболочка — POSIX `sh`, без GNU-only флагов (`grep -P`, `sed -E` с +расширениями, `mapfile`). Пути шаг строит от корня репозитория, а не от текущего +каталога: иначе его исход зависел бы от того, откуда он запущен. + +## Risks / Trade-offs + +- **Скрипт ищет число прозой документа, и переписанная строка сломает шаг** → + сообщение отказа называет место, где число не нашлось, поэтому чинится + однозначно и сразу. Ложное зелёное здесь невозможно по построению: не нашлось + — отказ. +- **Четыре места вместо двух — четыре места, которые надо править при подъёме + версии** → это цена решения 3, и она осознанная: молчаливо врущий документ + дороже одной лишней правки. +- **1.26 может оказаться несовместимой с зависимостью, которую мы ещё не + трогали** → проверяется до мерджа: `task image` собирает образ на объявленной + версии, а `go test ./...` идёт на хостовом go1.26.5. Обе проверки в критериях + приёмки. +- **Шаг проверяет согласованность чисел, но не то, что образ собирается** → + осознанный остаток. Собранный образ по-прежнему видит только тот, кто позвал + `task image` руками; сверка ловит класс расхождений, а не все отказы сборки. diff --git a/openspec/changes/archive/2026-08-12-go-1-26-upgrade/proposal.md b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/proposal.md new file mode 100644 index 0000000..4473f4f --- /dev/null +++ b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/proposal.md @@ -0,0 +1,51 @@ +## Why + +Сервис собирается тремя разными числами версии Go сразу: модуль требует одно, +сборочный образ берёт другое, памятка обещает третье, а README не называет +никакого. Расхождение этих чисел никто не проверяет, и один раз оно уже уехало в +работу: 2026-08-12 образ не собирался вовсе, а полный набор проверок и шесть +проходов ревью показали зелёное. Поймали случайно, руками. Пока сравнения нет, +то же самое повторится на следующем подъёме версии — а замечено будет в момент +выкладки, когда чинить дороже всего. + +## What Changes + +- Версия Go, на которой собирается сервис, поднимается до 1.26 и называется + **одним и тем же числом** в четырёх местах: требование модуля, сборочный + образ, памятка разработчику и README. Сегодня README не называет его вовсе — + строка заводится. +- В набор проверок добавляется шаг, который сравнивает объявленные числа между + собой и краснеет, называя все четыре места и число каждого. Шаг сравнивает + строки: он не собирает образ, не ходит в сеть, не требует docker и не + спрашивает, что за Go установлен на машине, — судит он по тому, что лежит в + репозитории. +- Собирать образ проверкой мы по-прежнему **не** будем — это дорого, и отказ + осознанный. Сравнение строк ловит тот же класс расхождений за доли секунды. +- Разница в третьем числе версии между модулем и образом остаётся законной: + сравниваются только первые два. + +## Capabilities + +### New Capabilities + +- `toolchain`: каким инструментом и какой его версии собирается сервис, и что об + этом проверяется до выкладки. Первое требование capability — согласованность + объявленной версии инструмента сборки и её проверка набором проверок. + +### Modified Capabilities + +Нет. Поведение сервиса для его потребителей не меняется: запись принимается, +расшифровывается и хранится ровно как прежде. + +## Impact + +- требование версии в `go.mod`; +- сборочный слой `Dockerfile`; +- строка о версии в `CLAUDE.md` и в `README.md`; +- набор шагов `task gate` в `Taskfile.yml` и описание семантики набора проверок + в `CLAUDE.md`; +- запись журнала дефектов `docs/review.md` за 2026-08-12 получает починку, + которую обещала. + +Выкладка этим изменением не запускается. Внешних зависимостей изменение не +трогает: PocketBase требует не ниже 1.25, и 1.26 это требование выполняет. diff --git a/openspec/changes/archive/2026-08-12-go-1-26-upgrade/review/report.md b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/review/report.md new file mode 100644 index 0000000..5afea38 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/review/report.md @@ -0,0 +1,620 @@ +# Ревью кода: go-1-26-upgrade — триаж + +База диффа: `HEAD` (коммит `aa20b22`), изменение целиком в рабочем дереве. Дата +прогона: 2026-08-12. + +## Сводка + +**Размер, сложность, метка.** Размер — среднее: `tasks.md` 20 шагов в 4 разделах, +8 файлов; `internal/` не тронут ни строкой. Сложность — знакомое: все узлы +названы поимённо до работы, шагов формы «разобраться/выяснить» нет. **Метка +`medium`** (максимум по осям), **режим — по графу**. Триггеры `docs/review.md` +проверены все три группы, ни один пункт не совпал. + +**Состояние гейта: ЗЕЛЁНЫЙ.** Подтверждено собственным прогоном триажа, а не +только отчётом прохода: `task gate BASE=HEAD` → exit 0. Все девять шагов зелёные, +включая новый `go-version`. Одно унаследованное замечание `tasks.py` +(`any-audio-source`: цель без задач и без тега `decomposed`) шаг не роняет и к +диффу отношения не имеет. + +### План разметки задачи с исходом по каждой теме + +| тема | дом | глубина | кто закрывает | исход | +|---|---|---|---|---| +| requirements | `openspec/specs/` + дельта `specs/toolchain/spec.md` | разбор | specs | **закрыта**, 4 находки (S1–S4) + 2 блока наблюдений | +| autotests | `CLAUDE.md`, раздел «Гейт» | — | autotests | **закрыта**, 3 находки (A1–A3) + отчёт гейта | +| conventions | `docs/conventions/` | разбор | code | **закрыта**, 3 находки (C1–C3) + 3 наблюдения ниже порога | +| architecture | `docs/architecture.md` + источник `docs/passport.md` | разбор | basics | **закрыта**, 1 находка (B1) + 4 пункта «дешевле переделать» | +| security | `docs/security.md` | разбор | basics | **закрыта**, находок нет: тема неприменима к диффу целиком | +| operations | `docs/architecture.md` §Эксплуатация + источник `docs/database.md` | разбор | basics | **закрыта**, 1 находка (B3); 4 вопроса из 6 неприменимы | + +**Тем без отчёта нет.** Все шесть тем ядра вернули отчёты; своих тем сверх ядра у +проекта нет. Ни одна тема не осталась непроверенной по причине «проход не +запускался». + +**Находок на входе:** 13 нумерованных (A1–A3, S1–S4, C1–C3, B1–B3) плюс 15 +ненумерованных содержательных пунктов (3 «поведение вне спеки», 5 «границы +спеки», 3 наблюдения ниже порога, 4 «дешевле переделать до мерджа») = **28 +позиций**. **Осталось в основных секциях: 6** — 2 блокирующие и 4 «стоит +исправить сейчас». Слито по причине 4 пары, понижено до гипотез 3, уехало в +promote 5, выброшено 6 (все названы поимённо). + +### Сигнал о заниженной метке + +**Сигнал подан двумя проходами независимо — `code` и `basics`.** Оба назвали одно +и то же: изменение вводит новое понятие (capability `toolchain` — первая, что +описывает не поведение сервиса, а инструмент разработки), новый каталог верхнего +уровня `scripts/` и новый шаг гейта; `design.md` сам записывает нерешённое +натяжение в размещении capability. Оба сказали, что метка `large` дала бы +отдельный проход `review-architecture`, и оба отказались решать за конвейер. + +**Провенанс один, приоритет два.** Согласие двух проходов — это одна модель, +высказавшаяся дважды: `confidence` оно не повышает, приоритет повышает. Одна +строка с двумя провенансами, а не два пункта. + +**Суждение триажа — факт для человека, не команда конвейеру:** + +1. **По записанному правилу разметка верна.** `docs/review.md`, «Триггеры метки»: + в группе «Крупное здесь» ближайший пункт — «каркас приложения: сборка + фронтенда, раздача статики и шаг гейта разом» — требует трёх вещей сразу, + здесь только шаг гейта. В «Незнакомое здесь» не совпал ни один из шести: форма + решения (сравнение строк `sed`/`awk`, без `go` и без docker) была названа до + работы. Отрицательный тест пройден: миграции, формата файла, контракта API и + имени ключа конфига изменение не трогает. `review-scope` не ошибся против + правила, которое у него было. +2. **Ось, на которую указали проходы, в правиле отсутствует.** Ни один триггер не + говорит о заведении новой capability и о новом каталоге верхнего уровня. А + именно эта ось дала **обе блокирующие находки прогона** — обе про канон, а не + про код. Сигнал верен по существу: правило разметки не видит того, что в этом + изменении оказалось самым дорогим. +3. **Перезапуска это не требует, метку задним числом не пересматривают.** Тема + `architecture` дома не лишилась и без отчёта не осталась — её закрыл `basics` + на глубине «разбор». Честный остаток: тему смотрел проход широкого профиля, а + не специализированный, и вопрос «правильно ли выбрано имя и дом capability» + остался без независимого разбора (H-1). +4. **Что с этим делать — не здесь.** Кандидат в правило вынесен в promote (P-5). + +--- + +## 1. Блокирует мердж (2 из 3) + +### B-1. Требование «ровно одно вхождение» станет нормой в форме, которая про `Dockerfile` уже неверна, а на `CLAUDE.md` уронит гейт на правдивой строке + +- Файл: `openspec/changes/go-1-26-upgrade/specs/toolchain/spec.md:16-18` против + `scripts/check-go-version.sh:127-131` и `:147-153`; правильные слова уже лежат в + `openspec/changes/go-1-26-upgrade/design.md:158-163` +- Severity: **major** | Confidence: **high** +- Действие: **развилка** +- Найдено проходами: `specs` (S1 и пункт «Границы спеки»), `basics` (пункт «Рамка + правила „ровно одно вхождение“»). **Слито триажем по причине:** причина одна — + требование написано пофайлово единым правилом, а четыре места устроены + по-разному. Правится одним абзацем. +- **Оракул — три прогона на копии дерева** (копии в scratchpad; рабочее дерево не + тронуто, `git status --porcelain` до и после совпадает): + 1. второй сборочный слой `FROM docker.io/library/golang:1.26-alpine AS second` → + **exit 0**. Норма гласит: «Каждое место MUST называть версию ровно один раз. + Второе вхождение числа в том же месте MUST считаться отказом», и перечень + мест включает `Dockerfile`. Код нормы не исполняет и исполнять не должен: + `collect Dockerfile "$(read_dockerfile)" 0` передаёт `strict=0` намеренно. + Контроль: тот же второй слой с `golang:1.25-alpine` → exit 1, «Dockerfile + называет несколько разных версий» — то есть совпадение слоёв проверяется, + единственность нет; + 2. в `CLAUDE.md` дописана правдивая строка `- прежде собирались на Go 1.25; долг + закрыт задачей go-1-26-upgrade` → **exit 1**, «CLAUDE.md называет несколько + разных версий»; + 3. в `README.md` дописан блок кода с `# нужен Go 1.26` → **exit 1**, «README.md + называет версию больше одного раза». +- Последствие. **Со стороны `Dockerfile`** — молчаливое расхождение нормы и кода: + после архивации нормой станет спека, а не `design.md`. Многослойная сборка с + двумя `FROM golang:` — законная форма. Ревьюер следующего изменения увидит + `strict=0`, прочтёт MUST и «починит» скрипт, уронив гейт на рабочем + `Dockerfile`; обратный исход не лучше — норма останется ложью, на которую + сошлются. **Со стороны документов** — гарантированный ложный красный: + `CLAUDE.md` по устройству ведёт историю (раздел «Гейт» прямо говорит «Два + прежних долга закрыты и здесь названы»), и первая же правдивая запись о прошлой + версии роняет шаг. По правилу проекта «Что считается сломанным — новый красный + шаг гейта… чинится прежде любой другой работы» это остановит работу, а + сообщение «называет несколько разных версий» толкает чинить не скрипт, а + исторический документ, то есть подделывать запись. `README.md` ловится тем же на + любом блоке кода с командой установки. +- Почему до мерджа: спека замерзает архивацией, после неё правка MUST — отдельное + изменение. Сегодня это один абзац. +- **Вопрос человеку:** + - **Вариант А (дешёвый, ожидаемый).** Развести правило по местам прямо в + требовании, дословно как уже написано в `design.md:158-163`: единственности + требовать от `go.mod`, `CLAUDE.md` и `README.md`, а от `Dockerfile` — + совпадения всех вхождений `FROM golang:`. Плюс сузить рамку для документов: + правило считает не файл целиком, а помеченную строку стека (или раздел + «Стек»/«Технологии»). Цена: абзац спеки + `read_doc` начинает читать раздел, а + не файл — несколько строк скрипта. Сценарий «Одно место называет два разных + числа» остаётся верным и правки не требует. + - **Вариант Б (дешевле сейчас, дороже потом).** Развести только `Dockerfile` + (правка чисто текстовая, кода не трогает), а цену «файл целиком» для + документов принять осознанно и записать остатком в спеке: «`CLAUDE.md` не + ведёт истории версий Go; запись о прошлой версии живёт в `docs/review.md`». + Тогда красный на истории — не сюрприз, а объявленный запрет. + +### B-2. Канонический перечень capability назовёт три из четырёх ровно в момент архивации, и промолчит именно о новой + +- Файл: `docs/architecture.md:11-26`; `openspec/changes/go-1-26-upgrade/tasks.md`, + раздел «3. Документы» (в нём `docs/architecture.md` нет) +- Severity: **minor** | Confidence: **high** +- Действие: **инлайн** +- Найдено проходами: `specs` (S3), `basics` (B1). **Дубль по причине, слит;** + предложение взято более широкое, от `basics`. +- Оракул — поимённые положения, все перепроверены триажем: + - `openspec/config.yaml:25-26` дословно: «Состояние спек и правило „первая + задача, трогающая поведение, заводит спеку своей capability“ — + docs/architecture.md, преамбула». Дом назначен, и он один; + - `docs/architecture.md:11` дословно: «Заведены три capability:»; `proposal.md` + заводит четвёртую; + - прецедент: прошлое изменение правило этот список **тем же коммитом**, что и + реализацию — `git show 01cc31d -- docs/architecture.md` даёт `-Заведены две + capability, и каждая описана частично:` / `+Заведены три capability:`; + - `tasks.md`, раздел 3, содержит ровно два пункта — `CLAUDE.md` и + `docs/review.md`; обзора архитектуры в нём нет; + - машинного оракула нет и быть не может: `CLAUDE.md`, «Гейт» — «согласованность + документов между собой и с кодом — её судят агенты, зовёт их скилл + `av-dev-docs:healthcheck`, и звать его надо руками». +- Последствие. После архивации в `openspec/specs/` появится четвёртая capability, + о которой единственный назначенный обзор молчит. Следующий, кто возьмётся за + версию инструмента сборки, пойдёт по указанному дому, четвёртой строки не найдёт + и либо заведёт вторую спеку на ту же тему, либо припишет требование в `pipeline` + — ровно то, от чего `design.md:79-82` отказался. Отказ молчаливый: гейт этого + класса не ловит по устройству. +- Предложение (инлайн, три правки): + 1. четвёртая строка перечня в `docs/architecture.md` — про `toolchain`, со + ссылкой на спеку и задачу-источник; + 2. оговорка к преамбуле: сегодня она читается «поведение системы здесь не + описывается — нормативно оно живёт в `openspec/specs/`», а `toolchain` + описывает **не** поведение сервиса; без оговорки преамбула становится + неверной в момент архивации; + 3. пункт в `tasks.md`, раздел «3. Документы», чтобы правка не потерялась. + +**Третий слот блокирующих не занят** — кандидатов нет: всё прочее либо не +замерзает мерджем, либо не имеет оракула. + +--- + +## 2. Стоит исправить сейчас (4 из 4) + +### N-1. Единственный новый страж проекта не покрыт ничем: следующая правка его регулярных выражений перестанет ловить случай молча + +- Файл: `scripts/check-go-version.sh` (весь); `tasks.md:96-111` +- Severity: **major** | Confidence: **high** +- Действие: **развилка** (новая работа, за границей объявленного scope) +- Найдено проходами: `autotests` (A1), `specs` (S4). **Дубль по причине, слит.** +- Оракул (перепроверено триажем): + - `find . -iname '*check-go-version*'` → ровно один файл, сам скрипт; тестов нет; + - `grep -rln 'check-go-version' --include='*_test.go' --include='*.bats' + --include='*test*.sh' .` → пусто; + - `task gate` прогоняет скрипт ровно на согласованном дереве, то есть проверяет + один сценарий дельты из четырнадцати — «Версии совпадают»; + - раздел «4. Проверка» в `tasks.md` перечисляет 11 сценариев, все `[x]`, но ни + один не зафиксирован ничем, кроме прозы: это разовый ручной прогон, а не + оракул; + - положение проекта, которое здесь нарушено, записано: `docs/review.md», + «Типовые узлы» → «Любой узел»: «изменённое место покрыто хоть одним + **проходящим** тестом». +- Последствие. POSIX-sh с разбором четырёх разных форм через `sed`/`awk` — класс + кода, где правка одного образца ломает смежный случай беззвучно. `go vet`, + `golangci-lint` и `gofmt` shell не видят; `shellcheck` в гейт сознательно не + введён. Правка третьего аргумента `collect` или образца `read_doc` снимает + проверку молча, и заметят это на следующем подъёме версии — примерно через год, + и ровно тем способом, каким был найден дефект 2026-08-12: образ перестал + собираться, и этого не увидел никто. Класс — «молчание»: страж перестаёт + стеречь, не сообщая об этом. +- **Вопрос человеку:** + - **А. Сейчас, в этом изменении.** Тест-скрипт рядом + (`scripts/check-go-version.test.sh`) и отдельный шаг гейта: десяток + мутационных прогонов на временной копии дерева — по одному на сценарий дельты. + Цена ~100 строк shell плюс шаг Taskfile. Плюс: страж проверен ровно тем + способом, каким `docs/review.md` велит проверять оракулы. + - **Б. Задачей урожая, вместе с `shellcheck` (P-1).** Один шаг гейта, гоняющий и + линтер оболочки, и мутационные прогоны. Плюс: не раздувает изменение, scope + остаётся заявленным. Минус: между мерджем и задачей страж не проверен ничем, и + правки в этот промежуток пройдут вслепую. + - **В. Принять остаток осознанно** и записать строкой в `docs/review.md`, + «Недоступно проверке» → «Перестали проверять сознательно», с ценой. Плюс: + честно и бесплатно. Минус: следующий промах этого класса будет уже вторым. + +### N-2. Новое машинное правило не попало в единственный индекс механизированного, и следующий проход конвенций будет сверять версии руками + +- Файл: `docs/conventions/README.md:50-65` +- Severity: **minor** | Confidence: **medium** +- Действие: **инлайн** +- Найдено проходом: `code` (C3) +- Оракул — поимённое положение конвенций: `docs/conventions/README.md:52-53` + («Проверяется командами из CLAUDE.md; прозой не дублируется») и `:64-65` («Не + названное здесь место механизации означает, что проход по конвенциям будет + добросовестно проверять уже проверенное»). Строка про `docs.py check` в той же + таблице — прямой прецедент внесения шагов гейта. +- Последствие. Дифф заводит новое машинное правило и не вносит его в индекс, + объявленный исчерпывающим. Следующий проход по конвенциям и следующий человек + будут считать согласованность версий непроверенной и сверять её руками. Заодно + это единственное место в `docs/conventions/`, откуда новый скрипт вообще был бы + виден: сегодня из дома конвенций он не виден никак. +- Предложение: строка в таблицу — `| Одно число версии Go в go.mod, Dockerfile, + CLAUDE.md и README.md | Taskfile.yml → шаг go-version + (scripts/check-go-version.sh) |`. + +### N-3. Сломанное окружение шаг объявит расхождением версий и назовёт невиновный файл + +- Файл: `scripts/check-go-version.sh:105-119`, `:147-153` +- Severity: **minor** | Confidence: **high** (проход давал `medium`; поднято + прогоном) +- Действие: **инлайн** +- Найдено проходом: `code` (C1) +- **Оракул — два прогона на копии дерева:** + 1. `chmod a-r README.md && sh scripts/check-go-version.sh` → + ``` + sed: can't read .../README.md: Permission denied + check-go-version: README.md не называет версию Go + Объявленная версия Go по местам: + go.mod 1.26 + Dockerfile 1.26 + CLAUDE.md 1.26 + README.md версия не названа + exit=1 + ``` + 2. прогон с `PATH`, где нет `awk` → **exit 127**, код вне словаря вовсе. + + Для сравнения, штатные пути проверены и корректны: нет файла места → 3, лишний + аргумент → 2, директива `toolchain` → 1, согласованное дерево → 0. +- Последствие. Значения добываются подстановкой команд в **аргументе** (`collect + README.md "$(read_doc "$readmemd")" 1`), а POSIX теряет код возврата подстановки, + стоящей в аргументе простой команды. Любой отказ чтения — файл есть, но + нечитаем; урезанный `PATH`; сломанный апплет busybox — даёт пустой вход, + `collect` видит `total -eq 0` и печатает утверждение **о содержимом файла** там, + где сломалось окружение. По словарю, который этот же дифф и расширил, 1 значит + «дрейф», 3 — «окружение», 4 — «внутренний сбой»; кода 4 скрипт не возвращает ни + на одном пути. +- **Честная оценка веса.** Ущерб невелик: `sed` печатает свою причину строкой + выше, так что человек у терминала подсказку видит, а `task gate` различает + только ноль и не-ноль. Вероятность низкая — CI у проекта нет, гейт гоняет + владелец на своей машине. Находка остаётся потому, что шаг гейта — источник, на + который смотрят как на истину, и ложное утверждение о конкретном файле из такого + источника дороже своей вероятности. +- Предложение: добывать значения через промежуточную переменную с проверкой кода, + а не в аргументе — четыре места, по одному на источник. + +### N-4. Цена отказа от `shellcheck` названа в памятке вчетверо, и вопрос от этого отложится снова + +- Файл: `CLAUDE.md:122-123`; то же в `design.md:37-38` +- Severity: **minor** | Confidence: **high** +- Действие: **инлайн** +- Найдено проходом: `code` (C2); сюда же ушла находка `autotests` A3 как замер + цены. +- Оракул — замер, снятый триажем на этом прогоне: + - `find . -path ./.git -prune -o -name '*.sh' -print` → ровно два файла: + `./scripts/check-go-version.sh` и `./docker/entrypoint.sh`; в гейте из них + один. Остальные три шага гейта — `docs.py`, `tasks.py`, `openspec.py` — Python + и лежат в плагинах вне репозитория; + - `shellcheck scripts/check-go-version.sh` → **одно** замечание, SC1007 на + `root=$(CDPATH= cd -- ... )`, и оно ложное: `CDPATH= ` — идиома очистки + переменной перед `cd`. Значит заведение линтера стоит одной строки шага плюс + одной директивы подавления. +- Последствие. Строка «`shellcheck` для скриптов гейта — их четыре, и заводить им + линтер надо разом» смешивает «скриптов в гейте четыре» с «`shellcheck` применим + к четырём». Применим он к одному. Следующий прочтёт в памятке цену «надо разом, + четыре штуки» и отложит вопрос снова — при том что нелинтуемым остаётся ровно + тот файл, который проход `code` был вынужден разбирать глазами построчно, а + триаж — прогонять руками. +- Предложение: переписать пункт по факту — «shell-скрипт в гейте один; три + соседних шага — Python в плагинах. Линтер не заведён; цена ему одна строка шага + и одно подавление SC1007». Ту же правку в `design.md`. + +--- + +## 3. Гипотезы без доказательства + +### H-1. Имя и дом capability `toolchain` замерзают мерджем (понижено: оракула нет) + +Из прохода `basics`, «Дешевле переделать до мерджа». После архивации спека уезжает +в `openspec/specs/` насовсем; `design.md` сам признаёт, что при одном требовании +переименовать дешевле, чем расщепить. Вопрос дома шире: четыре однородных шага +набора проверок живут в двух разных домах — у трёх плагинных дома нет вовсе, у +четвёртого есть нормативная спека, и эта асимметрия становится постоянной. + +**Почему понижено.** Оракула нет и построить его нечем: это суждение о +правильности имени, а не о поведении. Плюс два смягчающих факта: стадия ревью +дизайна (`specs` + rubric) прошла до кода и её замечания отработаны — вопрос уже +был на столе; `CLAUDE.md`, «Необратимое», имени capability не перечисляет. +Severity снята. + +**Остаток честный:** независимого архитектурного разбора у этого вопроса не было — +на метке `medium` отдельный проход не запускается, тему закрывал `basics` широким +профилем. Это и есть содержание сигнала о заниженной метке. + +### H-2. Пересборка того же коммита через месяц даст другой `ffmpeg` (понижено: замера нет, строка не из этого диффа) + +Из прохода `basics` (B3). `Dockerfile:26` — рантайм-слой `alpine:latest`, а +`Taskfile.yml:93` собирает с `--pull`: два образа из одного коммита с разницей в +неделю несут разные `ffmpeg`. Регрессия конвертации после такой пересборки +выглядит как задачи в `failed` при пустом диффе репозитория, и откат на прежний +коммит её не чинит. Класс тот же, ради которого написан весь новый шаг: +объявленное и собранное расходятся, и сверять некому. + +**Почему понижено.** Замера нет — ни одного числа о том, как часто и насколько +меняется `ffmpeg` в `alpine:latest`; снять на этом прогоне нечем. Строка внесена +не этим изменением, только активирована им. `Confidence: medium`. + +### H-3. «Два дома у семантики шага» — проверено и не подтвердилось + +Из прохода `basics`. Утверждение: семантика шага описана и в `CLAUDE.md` «Гейт», и +в спеке `toolchain`, а `openspec/config.yaml` предупреждает, что «второй дом факта +расходится с первым молча». + +**Проверено триажем:** спека `spec.md:93-96` не пересказывает словарь, а +**ссылается** на него — «раздел „Гейт“ в `CLAUDE.md` объявляет словарь общим». Это +уже правильная форма: один дом факта, вторая точка — ссылка. Последствия не +построено, находкой не выводится. + +--- + +## 4. Promote candidates + +- **P-1. `shellcheck` шагом набора проверок.** Цена замерена на этом прогоне: + файлов `.sh` два, в гейте один, единственное сегодняшнее замечание — ложный + SC1007 на идиому `CDPATH= cd`. Шаг стоит одной строки плюс одной директивы + подавления. +- **P-2. Род узла «скрипт набора проверок» в `docs/review.md`, «Типовые узлы».** + Сегодня перечень родов покрывает только рантайм. Скрипт гейта — новый род с + собственными проверяемыми свойствами: отличает «расхождение» от «сломанного + окружения», исход есть функция коммита, покрыт мутационным прогоном. Без этого + рода свойство «изменённое место покрыто хоть одним проходящим тестом» к shell не + приложено ничем, и находка N-1 в следующий раз опять будет добываться с нуля. +- **P-3. Обёртки `Taskfile` отдают 1, когда скрипта нет, а словарь велит 3.** Из + S2/B2, слитых по причине. **Но так делают все четыре обёртки** — `docs`, + `tasks`, `openspec` и новая `go-version`: новый шаг лишь повторил существующий + рисунок, и дефектом **этого** диффа это не является. Сам скрипт при отсутствующем + месте выходит корректно — 3 (проверено). Правило, а не правка: привести все + четыре обёртки к 3 разом либо убрать «или файл не найден» из описания кода 3 и + объявить `exit 1` нормой для «скрипта шага нет». +- **P-4. Норму «версия внешнего инструмента объявлена числом и сверяется» + распространить на рантайм-базу образа.** Из H-2. Non-Goal этого изменения + записан; кандидат в отдельную задачу. +- **P-5. Триггер метки: изменение, заводящее новую capability или новый каталог + верхнего уровня.** Из сигнала о заниженной метке. Сегодня «Триггеры метки» видят + только объём и незнакомость формы решения; ось «изменение трогает канон» в них + отсутствует, а на этом прогоне именно она дала обе блокирующие находки. + +--- + +## 5. Границы покрытия + +Секция не сокращается. Без неё формулировка «критичных проблем не обнаружено» +запрещена — и здесь она не употребляется. + +### План: темы, дома, глубины + +Все шесть тем ядра размечены, у всех есть дом, все закрыты — таблица в сводке. +**Тем без дома нет. Тем без отчёта нет. Своих тем сверх ядра проект не +объявляет.** Глубина «разбор» у пяти тем, у `autotests` глубина не назначалась. + +### Какие проходы запускались + +На метке `medium`, в режиме «по графу», запускались четыре: `autotests`, `specs`, +`code`, `basics`. Стадия ревью дизайна (`specs` + `rubric`) прошла раньше, до +кода, и её замечания отработаны — на этом прогоне она не повторялась. + +### Какие проходы не запускались и почему + +- **`review-architecture`** — не запускается на метке `medium` по устройству + графа; тема `architecture` отдана `basics`. Именно об этом сигнал двух проходов. +- **Проход независимой реализации** — снят из конвейера по стоимости. +- **Проход про идиоматичность языка** — упразднён. + +### Что каждый запущенный проход не мог проверить в принципе + +Оговорка о происхождении: **сырые выводы, поданные триажу, несут границы прозой, а +не блоком `Coverage of this pass` из контракта.** Перечень ниже восстановлен по +тому, что проходы написали, а не по их charter'ам, — и может быть неполон. Это +отдельная строка деградации. + +- `autotests` — не судит содержание кода; видит зелёное/красное и наличие тестов. + Прогон гейта не собирает образ (намеренно) и не считает покрытие изменённых строк. +- `specs` — судит соответствие кода дельта-спеке и обратно; не судит качество кода + вне нормы и не проверяет, нужна ли норма вообще. +- `code` — темы `conventions` плюс технический разбор; Go-кода дифф не содержит, + поэтому `logging.md`, `errors.md`, `config.md` неприменимы поимённо, и разбор + свёлся к shell, который ни один линтер проекта не видит. +- `basics` — темы `security`, `operations`, `architecture` широким профилем; на + этой метке заменяет специализированные проходы, а не дополняет их. +- **Триаж не находит ничего нового по определению**: работает с чужими выводами и + своими прогонами-оракулами. Пропуск любого прохода — его пропуск тоже. + +### Неприменимые темы и вопросы — названы, а не пропущены + +- **`security`: тема неприменима к диффу целиком.** `internal/` не тронут ни + строкой. Три вопроса темы из `docs/review.md` адресованы + `internal/service/transcribe.go` и `internal/metrics` — они не менялись, вопросы + остаются открытыми и после этого прогона. Периметр сборки дом объявляет вне + модели: `docs/security.md:233-235`. +- **`operations`: применимы 2 вопроса из 6.** Отказ соседа, повтор и + одновременность, остановка на середине, наблюдаемость, рост объёма — + неприменимы: рантайм не меняется. Три вопроса темы из `docs/review.md` к диффу + неприменимы и остаются открытыми. +- **`autotests`: вопрос темы из `docs/review.md:133-134`** адресован + job-конвейеру в `internal/`, которого дифф не трогает. +- **`conventions`: вопрос темы** («новая колонка правится во всех четырёх местах») + — **колонок изменение не трогает вовсе**; `internal/adapter/repo/pocketbase` не + изменён ни строкой. + +### Что осталось целиком на человеке + +**Не проверит ни один проход:** +- `operations`: поведение внешних сервисов под нагрузкой и на границах; +- `operations`: реальный профиль нагрузки; +- `security`: стойкость `ffmpeg` к вредоносному входу. + +**Перестали проверять сознательно:** +- `autotests`: разбор вывода настоящего `ffprobe` — решение и цена в + `docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`. + +**Своё, для этого изменения:** **собираемость образа на объявленной версии** — +требование «Объявленное число — то, на котором проект собирается» прямо оставляет +проверку человеку и запрещает вводить её в набор проверок. Косвенные свидетельства +положительные (`go version` → `go1.26.5`; гейт зелёный; образ `transcriber:dev` в +наличии), но **самой сборки триаж не запускал** — она объявлена сделанной пунктом +`tasks.md` 4.2. + +### Каких документов проекта не хватило + +- **`docs/review.md`, «Типовые ложноположительные» — есть и непуст (4 пункта), но + все четыре про рантайм `internal/`.** Дифф его не трогает, поэтому **проектных + ложноположительных для него не существует вовсе**: отсев вкусовщины шёл по общим + критериям устава, без проектного входа. +- **`CLAUDE.md`, «Инварианты» — раздел есть, 9 пунктов, и ни один не применим к + диффу.** Следствие названо прямо: **ни одна находка этого прогона не поднята до + `critical` по основанию «нарушен инвариант проекта» — сослаться не на что.** + Ранжирование велось по обратимости, выведенной из механики openspec, и это + **предположение триажа**, а не записанное правило проекта. +- **`CLAUDE.md`, «Необратимое» — имени capability, состава `openspec/specs/` и + раскладки `scripts/` в нём нет.** Поэтому «спека замерзает мерджем» — вывод из + механики openspec, а не проектная норма. +- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся» — дословно.** Значит + суждение «объём right-size» опирается на оценку триажа, а не на проектное число. + +### Сработавшие потолки + +- `basics` — **сообщил сам**: 3 находки при потолке 4, за срезом ничего. +- `code` — **сообщил частично**: «Потолок конвенций 1/4 — срез не сработал». Про + потолок технической половины не сказал ничего. +- `specs` — **не сообщил потолок вовсе.** 4 находки. **Это находка о прогоне:** + проход обязан сообщать потолок сам. +- `autotests` — **не сообщил потолок вовсе.** 3 находки. То же. +- **Триаж:** 2 из 3 блокирующих, 4 из 4 «стоит исправить сейчас». Секция «сейчас» + заполнена под завязку; ничего не выброшено молча. + +### Выброшено — поимённо + +1. **A2**: вопрос темы про шаг конвейера — к диффу не относится. +2. **A3**: `shellcheck` SC1007 — ложное срабатывание, действующего последствия нет; + содержание уехало замером в N-4 и P-1. +3. **Порядок шагов в `gate`**: самый дешёвый шаг стоит пятым. Вкусовщина по всем + трём условиям: поведения не меняет, стоимости следующего изменения не меняет, + записанной конвенции о порядке шагов в проекте нет. +4. **`proposal.md`: «Внешних зависимостей изменение не трогает»** против переезда + `smithy-go` из `// indirect` в прямые требования. Версия `v1.27.7` не менялась, + пакет импортируется в `internal/adapter/recognizer/yandex/s3.go:15` — штатный + результат заказанного `go mod tidy`. Прозаическая неточность без последствий. +5. **`CLAUDE.md:114-115`** «краснеет с именем недостающего плагина» — новый шаг + плагином не является. Последствия не построено; чинится вместе с P-3. +6. **`docs/review.md:194-198`**: преамбула журнала не сходится с верхней записью. + Расхождение приехало предыдущим коммитом, вне диффа. + +### Четыре строки, которые не принесёт ни один проход + +1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон его + не открывает. Для этого изменения это особенно весомо: `design.md` ссылается на + решения, но ни один проход не открывал `docs/adr/` и не проверял, не + противоречит ли новая норма уже принятому. +2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже + процессный. Всякое число в этом отчёте снято на этом прогоне и сопровождается + командой замера. +3. **Поимённая сверка с руководствами по стилю языка не задавалась ни одним + проходом.** Для этого изменения дыра шире обычного: основной артефакт — + POSIX-shell, у которого в проекте нет ни конвенции, ни линтера, ни руководства. +4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера + нет.** Вопрос «а можно ли было решить это принципиально иначе — например, одним + `go.mod` как источником истины и генерацией остальных трёх мест» никто не + задавал. + +### Среда прогона + +- **Рабочее дерево не тронуто.** Все оракулы добыты на копиях в scratchpad. `git + status --porcelain` до и после прогона совпадает символ в символ. +- **Запреты `CLAUDE.md` соблюдены:** боевой каталог данных не трогался, боевой + токен не запускался, в Yandex Cloud не ходили, выкладка не запускалась, + `testdata` не заводилась, временное — только в scratchpad. +- **Отказов доступа не было.** `deny: Read(./build)` на путях этого изменения не + сработал (capability названа `toolchain` именно поэтому). +- **Ограничение среды:** подагентам запрещено писать файлы отчётов. Отчёт возвращён + триажем текстом и записан сюда оркестратором. + +--- + +# Дополнение: перепроверка после отработки B-1 + +Записано оркестратором после того, как находки триажа были отработаны. Отчёт без +этого раздела сообщал бы о составе прогона неверно. + +## Что было сделано по находкам + +| Находка | Действие | Исход | +|---|---|---| +| B-1 | развилка → человеку | Выбран вариант А: правило множественности разведено по местам, рамка в документах сужена до раздела стека. Изменило требование и код | +| B-2 | инлайн | Четвёртая capability и оговорка о её природе — в преамбуле `docs/architecture.md`. Ссылка на спеку поставлена шагом синка: до архивации файла нет и `docs.py check` краснеет битой ссылкой | +| N-1 | развилка → человеку | Выбран вариант Б: задачей урожая, вместе с `shellcheck`. Между мерджем и той задачей страж не проверен ничем — названо остатком | +| N-2 | инлайн | Строка в таблице «Механизировано» `docs/conventions/README.md` | +| N-3 | инлайн | Проверка читаемости места: код 3 и сообщение о нечитаемости вместо «версия не названа» с кодом 1 | +| N-4 | инлайн | Цена отказа от `shellcheck` названа по замеру в `CLAUDE.md` и `design.md`; ложный `SC1007` подавлен в скрипте | + +## Второй прогон: только проход `specs` + +**Полный прогон ревью кода не повторялся.** Правка по B-1 изменила дельта-спеку и +код, поэтому перепрогнан **целенаправленно один проход** — `specs`, владеющий +темой `requirements`, чей дом и изменился. `autotests` заменён собственным +прогоном гейта оркестратором; `code` и `basics` не перезапускались. + +**Чем это ограничено, прямо:** технический разбор новых функций `section`, +`has_section` и `collect_doc` независимым проходом **не выполнялся** — их читал +только `specs` в своей оптике (соответствие норме) и оркестратор. Проход `code` +видел прежнюю редакцию скрипта. Триаж второй раз не запускался, поэтому находки +ниже не проходили дедупликации и добычи оракула независимым агентом — оракулы у +них свои, прогонами. + +**Разметка не повторялась,** хотя дельта-спека менялась. Причина названа: правка +сузила формулировку одного требования внутри уже размеченной capability, не +меняя ни набора capability, ни периметра узлов, ни списка тем — план тем остался +бы тем же. Это осознанное отступление от правила «дельта-спеки изменились — +повтори разметку», а не пропуск. + +## Находки перепроверки — три, все minor, все отработаны + +- **S1 закрыта по существу**, а не переформулировкой. Проверено обеими сторонами: + норма больше не требует единственности от сборочного образа, и код ровно это и + делает; правдивая историческая строка о прошлой версии в памятке даёт зелёное, + а второе число внутри раздела стека — красное. +- **F-1.** Таблица `design.md` продолжала велеть «ровно одно вхождение на файл» — + правило, обратное принятой норме, — и переживала бы мердж как единственное + описание того, как машина ищет число. Абзац-мотивировка вдобавок стал + фактически неверен. Переписаны оба. +- **F-2, дороже прочих.** Норма называла начало раздела и молчала о конце. + Раздел закрывался только заголовком того же уровня, а строка, похожая на + заголовок, внутри блока кода читалась как настоящий заголовок. Худший исход — + **ложное зелёное**: если строку версии из раздела убрать, а ниже по файлу + появится заголовок первого уровня и любое «Go 1.26», шаг добрал бы число из + чужого места и промолчал. Граница определена требованием и исполнена кодом; + контрольный прогон подтверждает, что ложное зелёное исчезло — шаг теперь + честно говорит «версия не названа». +- **F-3.** Отличие «сломанного окружения» от «пропавшей строки» держалось только + на коде: ни один сценарий его не требовал, автотеста нет, гейт гоняет скрипт + ровно на согласованном дереве. Записано требованием и сценарием. + +## Оракулы перепроверки + +Регрессионная батарея — **21 прогон**, все совпали с ожиданием: 4 мутации по +минору и 4 удаления строки версии (по одной на место), директива `toolchain`, +патч в теге, смена базы образа, два слоя одной версии, два слоя разных версий, +дубль внутри раздела, история вне раздела, пропавший раздел, `PATH` без `go`, +запуск из подкаталога, коды выхода 0/1/2/3. Отдельно проверено, что при +нечитаемом файле строка «не называет версию» не печатается ни разу. + +Сверх того: `openspec validate --strict` — valid; `task gate` — exit 0; +`task image` пересобран на `golang:1.26-alpine` — exit 0; `shellcheck` на скрипте +чист. + +## Что осталось открытым после отработки + +- **N-1: страж не покрыт ничем.** Решением человека уехало задачей урожая. Из + девятнадцати сценариев дельты машина гоняет один — тот, где всё сошлось; + остальные восемнадцать подтверждены разовыми прогонами. До закрытия той задачи + правки скрипта идут вслепую. +- **H-2: рантайм-база образа берётся «последней доступной».** Два образа из + одного коммита с разницей в неделю несут разные `ffmpeg`. Вне границ задачи, + уезжает урожаем. +- **Сигнал о заниженной метке** остаётся фактом для человека: ось «изменение + трогает канон» в правиле выбора метки отсутствует, а на этом прогоне именно она + дала обе блокирующие находки. Кандидат в правило — P-5. diff --git a/openspec/changes/archive/2026-08-12-go-1-26-upgrade/specs/toolchain/spec.md b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/specs/toolchain/spec.md new file mode 100644 index 0000000..57b07eb --- /dev/null +++ b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/specs/toolchain/spec.md @@ -0,0 +1,228 @@ +## ADDED Requirements + +### Requirement: Версия инструмента сборки объявлена одним числом + +Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во +всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование +модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`, +строка стека в `README.md`. + +Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться +свободным, как и база образа: образ обновляется своим темпом, и требовать от +него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег +читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него +первые два числа. + +Правило множественности у мест разное, потому что места устроены по-разному. + +**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз, +и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке, +`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST +считаться отказом: обновят одно, второе протухнет молча. За пределами раздела +число не читается вовсе — иначе памятка, которая по устройству ведёт историю +закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой +версии, а сообщение толкало бы чинить не проверку, а исторический документ. + +**Сборочный образ** единственности не требует: каждый слой — настоящий вход +сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST +требоваться совпадение мажора и минора, а не единственность. + +**Требование модуля** называется директивой `go` и по устройству файла +единственно. + +Граница раздела MUST быть определена, а не подразумеваться: раздел кончается +следующим заголовком того же или более высокого уровня, заголовок третьего уровня +и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри +блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе +открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда +норма его читать не велит. + +`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым** +местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных +числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс +расхождения, ради которого требование и заведено. + +#### Scenario: Все четыре места названы одинаково + +- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` + называют версию Go +- **WHEN** их читают подряд +- **THEN** мажор и минор совпадают во всех четырёх + +#### Scenario: Патч сборочного образа отличается законно + +- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine` +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: База сборочного образа сменилась + +- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm` +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: Раздел стека называет версию дважды + +- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза +- **WHEN** версии сравнивают +- **THEN** это расхождение, даже если оба числа одинаковы + +#### Scenario: Число за пределами раздела стека не читается + +- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например + записью о закрытом долге +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: Сборочный образ собран в два слоя + +- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: Слои сборочного образа разошлись между собой + +- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами +- **WHEN** версии сравнивают +- **THEN** это расхождение + +#### Scenario: Заголовок раздела встретился внутри блока кода + +- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть + строка, совпадающая с заголовком раздела стека, а ниже названо другое число +- **WHEN** версии сравнивают +- **THEN** число из примера не читается, и расхождением это не считается + +#### Scenario: Раздел стека закрыт заголовком верхнего уровня + +- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа + прошлая версия +- **WHEN** версии сравнивают +- **THEN** это число не читается, и расхождением не считается + +#### Scenario: Раздела стека нет вовсе + +- **GIVEN** в документе нет раздела, где называется версия +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет недостающий раздел + +#### Scenario: Модуль объявляет версию пятым местом + +- **GIVEN** `go.mod` содержит директиву `toolchain` +- **WHEN** версии сравнивают +- **THEN** это расхождение + +### Requirement: Объявленное число — то, на котором проект собирается + +Объявленная версия SHALL быть той, на которой сервис действительно собирается и +проходит тесты. Согласованность четырёх строк между собой этого не доказывает: +четыре одинаковых числа несуществующей версии требованию о согласованности +удовлетворяют, а собрать на них нельзя. + +Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок: +она требует сборки образа, а сборка образа набором проверок не делается +намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка +образа и тесты на объявленном числе не прогнаны. + +#### Scenario: Версию подняли + +- **GIVEN** объявленную версию Go подняли во всех четырёх местах +- **WHEN** изменение готовят к мерджу +- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты + +### Requirement: Расхождение версий роняет набор проверок + +Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные +версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение +отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну +разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то +же и неверными были именно они, а по сообщению о паре человек чинит не то место. + +Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать +установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход +его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что +стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект +2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное +число не проверял никто. + +Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети — +и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только +читать: файлов он не правит и разошедшихся мест не чинит. + +Коды выхода MUST следовать словарю прочих проверочных шагов проекта: 0 сошлось, +1 расхождение, 2 ошибка употребления, 3 окружение. Своего словаря шаг MUST не +заводить: раздел «Гейт» в `CLAUDE.md` объявляет словарь общим, и четвёртый шаг с +собственной семантикой сделал бы это утверждение неверным. + +Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места. +«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы +как совпадение. + +Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое +существует, но не читается, — это отказ окружения, и сообщение MUST говорить о +нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в +котором строка на месте, а сломаны права. + +#### Scenario: Разошёлся сборочный образ + +- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест +- **WHEN** запускают `task gate` +- **THEN** шаг сверки завершается отказом +- **AND** сообщение называет все четыре места и число каждого +- **AND** весь набор проверок краснеет + +#### Scenario: Разошлось требование модуля + +- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся + +#### Scenario: Разошлась памятка + +- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся + +#### Scenario: Разошёлся README + +- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет `README.md` среди разошедшихся + +#### Scenario: Версии совпадают + +- **GIVEN** все четыре места называют одно число +- **WHEN** запускают `task gate` +- **THEN** шаг сверки проходит с кодом 0 +- **AND** остальные шаги набора идут как прежде + +#### Scenario: Инструмента сборки нет на машине + +- **GIVEN** в `PATH` нет `go` вовсе +- **WHEN** запускают шаг сверки +- **THEN** исход и сообщение те же, что и при установленном `go` + +#### Scenario: Ни docker, ни сети нет + +- **GIVEN** docker недоступен и сети нет +- **WHEN** запускают шаг сверки +- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker + +#### Scenario: Шаг запущен не из корня проекта + +- **GIVEN** шаг запускают из подкаталога дерева +- **WHEN** он ищет свои четыре места +- **THEN** исход тот же, что и при запуске из корня + +#### Scenario: Место существует, но не читается + +- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет +- **WHEN** запускают шаг сверки +- **THEN** он завершается кодом окружения и говорит о нечитаемости места +- **AND** сообщения «версия не названа» не печатает + +#### Scenario: Версия не названа там, где должна быть + +- **GIVEN** одно из четырёх мест перестало называть версию Go +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет место, где число не нашлось diff --git a/openspec/changes/archive/2026-08-12-go-1-26-upgrade/tasks.md b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/tasks.md new file mode 100644 index 0000000..d01b7a7 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-go-1-26-upgrade/tasks.md @@ -0,0 +1,173 @@ +## Критерии приёмки + +### От постановки + +Перенесены дословно из записи задачи `tasks/items/go-1-26-upgrade.md`: закрытие +задачи удалит файл, а критерии обязаны его пережить. + +- Модуль, образ и документы называют одну версию Go. Оракул — `grep` по четырём + местам: `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`; все дают одно число. +- Сборка на объявленной версии проходит. Оракул — `task image` и + `CGO_ENABLED=0 go build ./...` на чистом дереве. +- Расхождение версий роняет гейт. Оракул — прогон `task gate` на дереве, где + версия в `Dockerfile` понижена на минор: шаг краснеет и называет оба числа. +- Совпадение гейт не роняет, а сам шаг не требует docker и работает без сети. + Оракул — `task gate` на неизменённом дереве и прогон с + `DOCKER_HOST=/dev/null`. +- Гейт зелёный целиком. Оракул — `task gate`. + +### От рубрики ревью дизайна + +Проход `review-rubric`, стадия ревью дизайна. Рубрика на род узла «проверочный +шаг набора проверок, читающий разнородные источники». + +- **Мутационный оракул на каждый источник.** Четыре прогона: по очереди понизить + минор в `go.mod`, `Dockerfile`, `CLAUDE.md`, `README.md`. В каждом шаг краснеет + и называет именно изменённый источник. Оракул — четыре прогона, четыре красных, + четыре разных сообщения. +- **Пустая выборка — отказ, а не согласие.** Четыре прогона: по очереди убрать + строку версии из каждого источника. Каждый даёт отказ с именем этого источника. +- **Исход — функция коммита, а не машины.** Оракул — прогон с `PATH`, из которого + убран `go`: тот же код выхода и тот же вывод, что и при установленном `go`. +- **Второе вхождение числа не проходит молча.** Оракул — дописать в `CLAUDE.md` + второе «Go 1.25» и прогнать шаг: он краснеет, а не судит по первому совпадению. +- **Патч и база образа свободны.** Оракул — два прогона: `golang:1.26.5-alpine` и + `golang:1.26-bookworm` в `Dockerfile`, оба зелёные. +- **Сообщение масштабируется на четыре места.** Оракул — прогон на дереве, где + один источник разошёлся с тремя: вывод содержит четыре пары «место: число». +- **Словарь кодов выхода объявлен.** Оракул — четыре прогона: сошлось, расхождение, + лишний аргумент, файл источника убран — дают 0, 1, 2 и 3. +- **Исход не зависит от рабочего каталога.** Оракул — прогон из корня и из + подкаталога дают один код выхода. +- **Шаг только читает.** Оракул — контрольная сумма дерева до и после прогона + совпадает, два прогона подряд дают одинаковый вывод. +- **Шаг не ходит в сеть.** Оракул — прогон под `strace -f -e + trace=socket,connect,sendto,recvfrom`: ни одного сетевого вызова. +- **Граница «чего шаг не проверяет» записана.** Оракул — раздел «Гейт» в + `CLAUDE.md` содержит строку о том, что сборка образа в набор проверок + по-прежнему не входит. +- **Директива `toolchain` не проходит незамеченной.** Оракул — дописать + `toolchain go1.27.0` в `go.mod` и прогнать шаг: он краснеет. + +## 1. Шаг сверки версий + +- [x] 1.1 Написать `scripts/check-go-version.sh` на POSIX `sh`: достать мажор и + минор из директивы `go` в `go.mod`, из тега `FROM golang:` в `Dockerfile`, из + строки стека в `CLAUDE.md` и из строки стека в `README.md` +- [x] 1.2 Пути строить от корня репозитория, а не от текущего каталога +- [x] 1.3 Не звать `go` ни в каком виде: ни `go mod edit`, ни `go list`, ни + `go env`. Исход обязан быть функцией содержимого файлов +- [x] 1.4 Тег образа разбирать по форме `golang:<мажор>.<минор>[.<патч>][-<база>]` + — патч и база отбрасываются +- [x] 1.5 Запретить директиву `toolchain` в `go.mod` — отказ с названием причины +- [x] 1.6 Второе вхождение числа в одном источнике — отказ; для `Dockerfile` все + `FROM golang:` обязаны давать одно число +- [x] 1.7 Отказ при расхождении: сообщение печатает все четыре места и число + каждого, а не одну пару +- [x] 1.8 Отказ при ненайденном числе: место названо поимённо, «нечего + сравнивать» исходом не считается +- [x] 1.9 Коды выхода по словарю проекта: 0 сошлось, 1 расхождение, 2 ошибка + употребления, 3 окружение +- [x] 1.10 Не использовать GNU-only флаги (`grep -P`, `sed -E` с расширениями, + `mapfile`); сделать скрипт исполняемым +- [x] 1.11 Завести шаг `go-version` в `Taskfile.yml` по образцу соседних шагов, + включая внятный отказ при отсутствии скрипта +- [x] 1.12 Включить шаг в `gate` + +## 2. Подъём версии до 1.26 + +- [x] 2.1 `go.mod`: директива `go 1.26.0` +- [x] 2.2 `Dockerfile`: сборочный слой на `golang:1.26-alpine` +- [x] 2.3 `CLAUDE.md`, раздел «Стек»: «Go 1.26» — ровно одно вхождение числа на + файл, включая абзац из шага 3.1 +- [x] 2.4 `README.md`, раздел «Технологии»: завести строку версии Go в форме, + которую находит скрипт +- [x] 2.5 `go mod tidy` и проверка, что `go.sum` не разъехался и директива + `toolchain` не появилась + +## 3. Документы + +- [x] 3.1 `CLAUDE.md`, раздел «Гейт»: новый шаг в перечне того, что красит + безусловно; строка о том, что сборка образа в гейт по-прежнему не входит; + словарь кодов выхода распространён на четвёртый проверочный шаг +- [x] 3.2 `docs/review.md`: запись за 2026-08-12 получает строку о том, чем + дефект закрыт +- [x] 3.3 `docs/architecture.md`, преамбула: четвёртая capability в перечне и + оговорка о том, что она нормирует не поведение сервиса (находка ревью B-2). + Ссылка на спеку ставится шагом синка, после архивации: до неё файла нет и + `docs.py check` краснеет битой ссылкой +- [x] 3.4 `docs/conventions/README.md`, таблица «Механизировано»: строка про + новое машинное правило (находка ревью N-2) + +## 5. Отработка находок ревью кода + +Отчёт триажа — `openspec/changes/go-1-26-upgrade/review/report.md`. + +- [x] 5.1 N-3: нечитаемое место даёт код 3 с честным сообщением, а не «версия не + названа» с кодом 1. Проверка читаемости стоит рядом с проверкой существования: + подстановка команд в аргументе теряет код возврата, а в `read_doc` статус + конвейера берётся от последней команды +- [x] 5.2 N-4: цена отказа от `shellcheck` названа по замеру — один shell-скрипт, + а не четыре. Правка в `CLAUDE.md` и в `design.md` +- [x] 5.3 Подавление ложного `SC1007` в скрипте, чтобы заведение линтера потом + стоило ровно одной строки шага. `shellcheck` на скрипте чист + +## 6. B-1: правило множественности разведено по местам + +Находка B-1 меняла требование, поэтому прошла через чекпоинт заново. Решение +человека: развести правило по местам и сузить рамку до раздела. + +- [x] 6.1 Требование: единственность — от раздела стека в документах, от + сборочного образа — совпадение всех вхождений `FROM golang:`, требование модуля + единственно по устройству файла +- [x] 6.2 Пять новых сценариев: дубль в разделе, число вне раздела, два слоя с + одной версией, два слоя с разными, пропавший раздел +- [x] 6.3 Скрипт читает раздел (`## Стек` в памятке, `## Технологии` в README), а + не файл целиком +- [x] 6.4 Пропавший раздел — свой исход с именем раздела, а не «версия не названа» +- [x] 6.5 Прогоны: история о прошлой версии вне раздела — зелено; пример команды в + README вне раздела — зелено; дубль внутри раздела — красно; два слоя одной + версии — зелено; два слоя разных — красно; раздела нет — красно с его именем +- [x] 6.6 Регрессия прежней батареи: 8 мутаций по местам, `toolchain`, патч и база + тега, `PATH` без `go`, подкаталог, коды 0/1/2/3 — все как прежде +- [x] 6.7 `openspec validate --strict` и `task gate` зелёные + +## 7. Отработка перепроверки спек после правки B-1 + +Целевой перепрогон прохода `specs`: правка коснулась ровно его темы. Прежняя +находка S1 подтверждена закрытой по существу; три новые, все minor. + +- [x] 7.1 F-2, ложное зелёное: раздел кончался только заголовком того же уровня, а + заголовок внутри блока кода читался как настоящий — число доставалось из-за + границы раздела. Граница определена в требовании и исполнена в коде: следующий + заголовок того же или более высокого уровня, блок кода заголовков не даёт, + хвостовые пробелы в заголовке не значат ничего +- [x] 7.2 F-2: два сценария — заголовок раздела внутри блока кода, раздел закрыт + заголовком верхнего уровня +- [x] 7.3 F-3: отличие «сломанного окружения» от «пропавшей строки» записано + требованием и сценарием, а не только кодом +- [x] 7.4 F-1: таблица `design.md` велела правило, обратное принятой норме + («одно вхождение на файл»), и абзац-мотивировка стал фактически неверен — + переписаны оба +- [x] 7.5 Регрессия 21 прогоном: 8 мутаций по местам, `toolchain`, патч и база + тега, два слоя одной и разных версий, дубль в разделе, история вне раздела, + пропавший раздел, `PATH` без `go`, подкаталог, коды 0/1/2/3 — все совпали с + ожиданием +- [x] 7.6 `shellcheck` на скрипте чист + +## 4. Проверка + +- [x] 4.1 `CGO_ENABLED=0 go build ./...` и `go test ./...` на go1.26.5 +- [x] 4.2 `task image` собирает образ на `golang:1.26-alpine` +- [x] 4.3 Четыре мутации по минору — по одной на источник; в каждой шаг краснеет + и называет изменённый источник; дерево возвращается как было +- [x] 4.4 Четыре мутации удалением строки версии — по одной на источник; в каждой + отказ называет место +- [x] 4.5 Прогон с `PATH` без `go`, прогон с `DOCKER_HOST=/dev/null`, прогон под + `strace` без сетевых вызовов — исход тот же +- [x] 4.6 Прогон из подкаталога — тот же код выхода +- [x] 4.7 Прогоны на `golang:1.26.5-alpine` и `golang:1.26-bookworm` — зелёные +- [x] 4.8 Прогон с дописанным `toolchain go1.27.0` — красный +- [x] 4.9 Четыре прогона на коды выхода: 0, 1, 2, 3 +- [x] 4.10 Контрольная сумма дерева до и после прогона совпадает +- [x] 4.11 `task gate` целиком зелёный diff --git a/openspec/specs/toolchain/spec.md b/openspec/specs/toolchain/spec.md new file mode 100644 index 0000000..702963d --- /dev/null +++ b/openspec/specs/toolchain/spec.md @@ -0,0 +1,241 @@ +# toolchain Specification + +## Purpose + +Каким инструментом и какой его версии собирается сервис, и что об этом +проверяется до выкладки. Заведена задачей `go-1-26-upgrade` 2026-08-12 по +дефекту, записанному в `docs/review.md` за то же число: сборочный образ разошёлся +с требованием модуля, образ перестал собираться, а восемь шагов гейта и шесть +проходов ревью показали зелёное. + +Capability нормирует **не поведение сервиса** для его потребителей, а поведение +инструмента разработки; потребитель у неё другой — тот, кто собирает сервис. Это +осознанное исключение, и оно названо в преамбуле `docs/architecture.md`. + +## Requirements +### Requirement: Версия инструмента сборки объявлена одним числом + +Проект SHALL объявлять версию Go, на которой собирается сервис, одинаково во +всех местах, где она названа. Мест ровно четыре, и перечень закрыт: требование +модуля в `go.mod`, сборочный образ в `Dockerfile`, строка стека в `CLAUDE.md`, +строка стека в `README.md`. + +Сравниваются мажор и минор. Третье число у сборочного образа MUST оставаться +свободным, как и база образа: образ обновляется своим темпом, и требовать от +него совпадения по патчу значило бы краснеть на каждом его обновлении. Тег +читается по форме `golang:<мажор>.<минор>[.<патч>][-<база>]`, и берутся из него +первые два числа. + +Правило множественности у мест разное, потому что места устроены по-разному. + +**Документы** — `CLAUDE.md` и `README.md` — MUST называть версию ровно один раз, +и считается это **не по файлу, а по разделу стека**: `## Стек` в памятке, +`## Технологии` в README. Второе вхождение числа **в этом разделе** MUST +считаться отказом: обновят одно, второе протухнет молча. За пределами раздела +число не читается вовсе — иначе памятка, которая по устройству ведёт историю +закрытых долгов, роняла бы проверку на первой же правдивой строке о прошлой +версии, а сообщение толкало бы чинить не проверку, а исторический документ. + +**Сборочный образ** единственности не требует: каждый слой — настоящий вход +сборки, и многослойная сборка законна. От всех вхождений `FROM golang:` MUST +требоваться совпадение мажора и минора, а не единственность. + +**Требование модуля** называется директивой `go` и по устройству файла +единственно. + +Граница раздела MUST быть определена, а не подразумеваться: раздел кончается +следующим заголовком того же или более высокого уровня, заголовок третьего уровня +и ниже остаётся внутри раздела, а строка, похожая на заголовок, но лежащая внутри +блока кода, заголовком MUST не считаться. Без этого пример в чужом разделе +открывал бы раздел стека на пустом месте, и число доставалось бы оттуда, откуда +норма его читать не велит. + +`go.mod` MUST не содержать директиву `toolchain`. Она называет версию **пятым** +местом, которого перечень не знает: при `toolchain go1.27.0` четыре объявленных +числа сойдутся, а собирать будет пятое — то есть вернётся тот самый класс +расхождения, ради которого требование и заведено. + +#### Scenario: Все четыре места названы одинаково + +- **GIVEN** дерево проекта, где `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` + называют версию Go +- **WHEN** их читают подряд +- **THEN** мажор и минор совпадают во всех четырёх + +#### Scenario: Патч сборочного образа отличается законно + +- **GIVEN** `go.mod` требует `1.26.0`, а образ собирается на `golang:1.26.5-alpine` +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: База сборочного образа сменилась + +- **GIVEN** образ переехал с `golang:1.26-alpine` на `golang:1.26-bookworm` +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: Раздел стека называет версию дважды + +- **GIVEN** раздел стека в `CLAUDE.md` называет версию два раза +- **WHEN** версии сравнивают +- **THEN** это расхождение, даже если оба числа одинаковы + +#### Scenario: Число за пределами раздела стека не читается + +- **GIVEN** `CLAUDE.md` вне раздела стека упоминает прошлую версию Go — например + записью о закрытом долге +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: Сборочный образ собран в два слоя + +- **GIVEN** `Dockerfile` содержит два `FROM golang:` с одним мажором и минором +- **WHEN** версии сравнивают +- **THEN** расхождением это не считается + +#### Scenario: Слои сборочного образа разошлись между собой + +- **GIVEN** `Dockerfile` содержит два `FROM golang:` с разными минорами +- **WHEN** версии сравнивают +- **THEN** это расхождение + +#### Scenario: Заголовок раздела встретился внутри блока кода + +- **GIVEN** документ в чужом разделе показывает пример, внутри которого есть + строка, совпадающая с заголовком раздела стека, а ниже названо другое число +- **WHEN** версии сравнивают +- **THEN** число из примера не читается, и расхождением это не считается + +#### Scenario: Раздел стека закрыт заголовком верхнего уровня + +- **GIVEN** после раздела стека идёт заголовок первого уровня, а ниже названа + прошлая версия +- **WHEN** версии сравнивают +- **THEN** это число не читается, и расхождением не считается + +#### Scenario: Раздела стека нет вовсе + +- **GIVEN** в документе нет раздела, где называется версия +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет недостающий раздел + +#### Scenario: Модуль объявляет версию пятым местом + +- **GIVEN** `go.mod` содержит директиву `toolchain` +- **WHEN** версии сравнивают +- **THEN** это расхождение + +### Requirement: Объявленное число — то, на котором проект собирается + +Объявленная версия SHALL быть той, на которой сервис действительно собирается и +проходит тесты. Согласованность четырёх строк между собой этого не доказывает: +четыре одинаковых числа несуществующей версии требованию о согласованности +удовлетворяют, а собрать на них нельзя. + +Проверка эта MUST оставаться за человеком и MUST не входить в набор проверок: +она требует сборки образа, а сборка образа набором проверок не делается +намеренно — дорого. Подъём версии MUST не уезжать в основную ветку, пока сборка +образа и тесты на объявленном числе не прогнаны. + +#### Scenario: Версию подняли + +- **GIVEN** объявленную версию Go подняли во всех четырёх местах +- **WHEN** изменение готовят к мерджу +- **THEN** до мерджа на этой версии прогнаны сборка образа и тесты + +### Requirement: Расхождение версий роняет набор проверок + +Набор проверок `task gate` SHALL включать шаг, который сравнивает объявленные +версии между собой и MUST завершаться отказом, когда они разошлись. Сообщение +отказа MUST называть **все четыре места и прочитанное в каждом число** — не одну +разошедшуюся пару: в дефекте 2026-08-12 три места из четырёх говорили одно и то +же и неверными были именно они, а по сообщению о паре человек чинит не то место. + +Шаг MUST судить по содержимому файлов репозитория и MUST не спрашивать +установленный инструмент — ни `go version`, ни `go env`, ни `GOTOOLCHAIN`. Исход +его MUST быть функцией коммита, а не машины: шаг, чей ответ зависит от того, что +стоит на хосте, воспроизводит ровно ту подмену, которая держала дефект +2026-08-12 невидимым — там `go build ./...` шёл на хостовом Go, а объявленное +число не проверял никто. + +Шаг MUST работать сравнением строк — без сборки образа, без docker и без сети — +и MUST не зависеть от рабочего каталога, из которого запущен. Шаг MUST только +читать: файлов он не правит и разошедшихся мест не чинит. + +Коды выхода MUST следовать словарю прочих проверочных шагов проекта: 0 сошлось, +1 расхождение, 2 ошибка употребления, 3 окружение. Своего словаря шаг MUST не +заводить: раздел «Гейт» в `CLAUDE.md` объявляет словарь общим, и четвёртый шаг с +собственной семантикой сделал бы это утверждение неверным. + +Место, где числа не нашлось вовсе, MUST считаться отказом с именем этого места. +«Нечего сравнивать» исходом MUST не быть: пропавшая строка иначе выглядела бы +как совпадение. + +Отказ чтения места MUST не выглядеть как отсутствие числа. Место, которое +существует, но не читается, — это отказ окружения, и сообщение MUST говорить о +нечитаемости, а не о ненайденной версии: иначе шаг отправляет чинить документ, в +котором строка на месте, а сломаны права. + +#### Scenario: Разошёлся сборочный образ + +- **GIVEN** `Dockerfile` называет версию, отличную от прочих трёх мест +- **WHEN** запускают `task gate` +- **THEN** шаг сверки завершается отказом +- **AND** сообщение называет все четыре места и число каждого +- **AND** весь набор проверок краснеет + +#### Scenario: Разошлось требование модуля + +- **GIVEN** `go.mod` называет версию, отличную от прочих трёх мест +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет `go.mod` среди разошедшихся + +#### Scenario: Разошлась памятка + +- **GIVEN** `CLAUDE.md` называет версию, отличную от прочих трёх мест +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет `CLAUDE.md` среди разошедшихся + +#### Scenario: Разошёлся README + +- **GIVEN** `README.md` называет версию, отличную от прочих трёх мест +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет `README.md` среди разошедшихся + +#### Scenario: Версии совпадают + +- **GIVEN** все четыре места называют одно число +- **WHEN** запускают `task gate` +- **THEN** шаг сверки проходит с кодом 0 +- **AND** остальные шаги набора идут как прежде + +#### Scenario: Инструмента сборки нет на машине + +- **GIVEN** в `PATH` нет `go` вовсе +- **WHEN** запускают шаг сверки +- **THEN** исход и сообщение те же, что и при установленном `go` + +#### Scenario: Ни docker, ни сети нет + +- **GIVEN** docker недоступен и сети нет +- **WHEN** запускают шаг сверки +- **THEN** он отрабатывает и даёт тот же исход, что и при доступном docker + +#### Scenario: Шаг запущен не из корня проекта + +- **GIVEN** шаг запускают из подкаталога дерева +- **WHEN** он ищет свои четыре места +- **THEN** исход тот же, что и при запуске из корня + +#### Scenario: Место существует, но не читается + +- **GIVEN** файл одного из мест на диске есть, но прав на чтение нет +- **WHEN** запускают шаг сверки +- **THEN** он завершается кодом окружения и говорит о нечитаемости места +- **AND** сообщения «версия не названа» не печатает + +#### Scenario: Версия не названа там, где должна быть + +- **GIVEN** одно из четырёх мест перестало называть версию Go +- **WHEN** запускают шаг сверки +- **THEN** он завершается отказом и называет место, где число не нашлось diff --git a/scripts/check-go-version.sh b/scripts/check-go-version.sh new file mode 100755 index 0000000..fa915fb --- /dev/null +++ b/scripts/check-go-version.sh @@ -0,0 +1,243 @@ +#!/bin/sh +# Сверяет объявленную версию Go во всех местах, где она названа: требование +# модуля, сборочный образ, памятка и README. Сравниваются мажор и минор; патч и +# база сборочного образа свободны. +# +# Шаг судит по содержимому репозитория и только по нему: команда `go` не +# зовётся вовсе. Иначе исход зависел бы от установленного тулчейна и от +# GOTOOLCHAIN, а при непустом GOTOOLCHAIN `go` вправе уйти в сеть за нужной +# версией — то есть шаг перестал бы быть функцией коммита. Ровно эта подмена +# держала дефект 2026-08-12 невидимым: `go build ./...` шёл на хостовом Go и был +# зелёным, пока образ не собирался. +# +# Коды выхода — общий словарь проверочных шагов проекта (CLAUDE.md, «Гейт»): +# 0 сошлось, 1 расхождение, 2 ошибка употребления, 3 окружение. + +set -eu + +me=check-go-version + +if [ "$#" -ne 0 ]; then + cat >&2 <<'EOF' +Использование: check-go-version.sh + +Сверяет объявленную версию Go в go.mod, Dockerfile, CLAUDE.md и README.md. +Аргументов не принимает. Коды выхода: 0 сошлось, 1 расхождение, +2 ошибка употребления, 3 окружение. +EOF + exit 2 +fi + +# Корень считается от самого скрипта, а не от текущего каталога: иначе исход +# зависел бы от того, откуда шаг запустили. +# +# `CDPATH= ` — очистка переменной перед `cd`, а не забытый пробел в присваивании: +# непустой CDPATH увёл бы `cd` в чужой каталог. shellcheck читает это как SC1007, +# и подавление стоит здесь, чтобы заведение линтера потом стоило ровно одной +# строки шага. +# shellcheck disable=SC1007 +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." 2>/dev/null && pwd) || { + echo "$me: не удалось определить корень репозитория" >&2 + exit 3 +} + +# Перечень мест закрыт и лежит здесь одним списком. Пятое место, о котором никто +# не знает, — это и есть тот дефект, против которого написан шаг, поэтому +# перечень не расползается по коду. +gomod=$root/go.mod +dockerfile=$root/Dockerfile +claudemd=$root/CLAUDE.md +readmemd=$root/README.md + +# Нечитаемое место проверяется здесь, а не при разборе, и вот почему. Значения +# добываются подстановкой команд в аргументе, а она теряет код возврата; в +# `read_doc` вдобавок конвейер, чей статус берётся от последней команды. Отказ +# чтения дал бы пустой вход, и шаг сказал бы «README.md не называет версию» — +# то есть отправил бы чинить документ, в котором строка на месте, а сломаны +# права. Отказ окружения обязан звучать как отказ окружения. +for f in "$gomod" "$dockerfile" "$claudemd" "$readmemd"; do + if [ ! -f "$f" ]; then + echo "$me: нет файла ${f#"$root"/}" >&2 + exit 3 + fi + if [ ! -r "$f" ]; then + echo "$me: файл ${f#"$root"/} нечитаем" >&2 + exit 3 + fi +done + +# Число непустых строк на входе. +count_lines() { + awk 'NF { n = n + 1 } END { print n + 0 }' +} + +# Различные непустые значения на входе. +distinct() { + awk 'NF' | sort -u +} + +# Директива `go` в go.mod: первые два числа. +read_gomod() { + sed -n 's/^go[[:space:]]\{1,\}\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' "$gomod" +} + +# Все теги golang в Dockerfile: golang:<мажор>.<минор>[.<патч>][-<база>]. +# Патч и база отбрасываются — спека объявила их свободными. +read_dockerfile() { + sed -n \ + 's/^[Ff][Rr][Oo][Mm][[:space:]].*golang:\([0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' \ + "$dockerfile" +} + +# Тело раздела документа. Раздел кончается следующим заголовком того же или более +# высокого уровня; заголовок внутри блока кода заголовком не считается, иначе +# пример в чужом разделе открыл бы «раздел стека» на пустом месте. Хвостовые +# пробелы в заголовке markdown не рендерит, поэтому и здесь они не значат ничего. +section() { + awk -v want="$2" ' + /^```/ { fence = !fence; if (inside) print; next } + !fence && (/^# / || /^## /) { + line = $0 + sub(/[[:space:]]+$/, "", line) + inside = (line == want) + next + } + inside { print } + ' "$1" +} + +# Есть ли в документе раздел с таким заголовком. Правила те же, что у section: +# иначе «раздел есть» и «раздел читается» разошлись бы на первом же примере. +has_section() { + awk -v want="$2" ' + /^```/ { fence = !fence; next } + !fence && (/^# / || /^## /) { + line = $0 + sub(/[[:space:]]+$/, "", line) + if (line == want) { found = 1 } + } + END { exit(found ? 0 : 1) } + ' "$1" +} + +# Все вхождения образца `Go <мажор>.<минор>` в разделе стека документа. +# +# Читается именно раздел, а не файл целиком. `CLAUDE.md` по устройству ведёт +# историю закрытых долгов, и правдивая строка о прошлой версии уронила бы шаг +# сообщением «называет версию больше одного раза» — то есть послала бы чинить +# исторический документ вместо проверки. Тем же ловился бы любой пример команды +# в README. +# +# Первый sed ставит каждое вхождение на свою строку: два числа в одной строке +# иначе слились бы в одно, и второе, протухшее, осталось бы невидимым. +read_doc() { + section "$1" "$2" | sed -e 's/Go [0-9][0-9]*\.[0-9][0-9]*/\ +&\ +/g' | sed -n 's/^Go \([0-9][0-9]*\.[0-9][0-9]*\)$/\1/p' +} + +failed=0 +report='' +collected='' + +fail() { + echo "$me: $1" >&2 + failed=1 +} + +add_report() { + line=$(printf ' %-11s %s' "$1" "$2") + report="$report$line +" +} + +# Добывает число из места и кладёт его в `collected`; пусто — значит не добыто. +# Третий аргумент: 1 — место обязано называть версию ровно один раз. +# +# Функция не зовётся в подстановке команд намеренно: подоболочка потеряла бы и +# флаг отказа, и отчёт. +collect() { + place=$1 + values=$2 + strict=$3 + + collected='' + total=$(printf '%s\n' "$values" | count_lines) + uniq=$(printf '%s\n' "$values" | distinct) + uniq_total=$(printf '%s\n' "$uniq" | count_lines) + + if [ "$total" -eq 0 ]; then + add_report "$place" 'версия не названа' + fail "$place не называет версию Go" + return 0 + fi + + if [ "$uniq_total" -gt 1 ]; then + add_report "$place" "$(printf '%s' "$uniq" | tr '\n' '/') — разные числа" + fail "$place называет несколько разных версий" + return 0 + fi + + if [ "$strict" -eq 1 ] && [ "$total" -gt 1 ]; then + add_report "$place" "$uniq — названа $total раз(а)" + fail "$place называет версию больше одного раза" + return 0 + fi + + collected=$uniq + add_report "$place" "$uniq" +} + +# Директива toolchain — пятое место, которого перечень не знает: четыре числа +# сойдутся, а собирать будет пятое. +if grep '^toolchain[[:space:]]' "$gomod" >/dev/null 2>&1; then + fail 'go.mod содержит директиву toolchain — она называет версию пятым местом' +fi + +# Раздел, в котором документ называет версию. За его пределами число не читается. +claude_section='## Стек' +readme_section='## Технологии' + +# Пропавший раздел — отдельный исход: он говорит «искать негде», а не «версия не +# названа», и чинится другим движением. +collect_doc() { + place=$1 + path=$2 + heading=$3 + + collected='' + if ! has_section "$path" "$heading"; then + add_report "$place" "нет раздела «$heading»" + fail "в $place нет раздела «$heading», где называется версия" + return 0 + fi + collect "$place" "$(read_doc "$path" "$heading")" 1 +} + +# Документы называют версию прозой, и второе вхождение в разделе стека — не +# дубликат, а второе утверждение: обновят одно, второе протухнет молча. У +# Dockerfile иначе: каждый сборочный слой — настоящий вход сборки, и от них +# требуется совпадение, а не единственность. +collect go.mod "$(read_gomod)" 1 +v_gomod=$collected +collect Dockerfile "$(read_dockerfile)" 0 +v_docker=$collected +collect_doc CLAUDE.md "$claudemd" "$claude_section" +v_claude=$collected +collect_doc README.md "$readmemd" "$readme_section" +v_readme=$collected + +if [ "$failed" -eq 0 ]; then + found=$(printf '%s\n%s\n%s\n%s\n' \ + "$v_gomod" "$v_docker" "$v_claude" "$v_readme" | distinct | count_lines) + if [ "$found" -ne 1 ]; then + fail 'объявленные версии Go разошлись' + fi +fi + +if [ "$failed" -ne 0 ]; then + printf 'Объявленная версия Go по местам:\n%s' "$report" >&2 + exit 1 +fi + +exit 0