#!/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
