# 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 следовать общему словарю проверочных шагов проекта; словарь объявляет раздел «Гейт» в `CLAUDE.md`, и здесь он не повторяется. Своего словаря шаг MUST не заводить: четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. Место, где числа не нашлось вовсе, 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** он завершается отказом и называет место, где число не нашлось