diff --git a/bin/doctor b/bin/doctor index 3043ad7..8c21ce7 100755 --- a/bin/doctor +++ b/bin/doctor @@ -327,6 +327,44 @@ if [ "$ONLINE" -eq 1 ]; then fi fi +# --- toolchain: golangci-lint must be built with Go >= GOROOT --------------- +# infra#375, second recurrence. golangci-lint embeds the go/types export-data +# reader from the Go it was BUILT with. When GOROOT is newer, that reader +# cannot decode the stdlib and the linter dies: +# +# panic: file requires newer Go version go1.27 (application built with go1.26) +# +# An earlier form printed a typecheck error naming the user's own file, which +# sends people to debug code that is fine. It cost time in two repos before +# anyone recognised it as a host fault rather than a repo fault. +# +# It is invisible to CI -- the runner image has matching versions -- so it +# breaks exactly the local `task check` that AGENT.md requires before a push, +# and nothing else. That is why it belongs in doctor: the signal was missing, +# not the fix. +# +# Fails open on anything unparseable. A host with no Go toolchain is a normal +# host, not a broken one, and doctor runs in a SessionStart hook everywhere. +ver_lt() { + # 0 when $1 < $2 on major.minor. Deliberately not sort -V: this is POSIX sh + # that also runs on macOS, and the export-data format is minor-versioned, so + # patch level does not enter into it. + _a=${1%%.*}; _ar=${1#*.}; _am=${_ar%%.*} + _b=${2%%.*}; _br=${2#*.}; _bm=${_br%%.*} + case "$_a$_am$_b$_bm" in *[!0-9]*) return 1 ;; esac + [ "$_a" -lt "$_b" ] && return 0 + [ "$_a" -gt "$_b" ] && return 1 + [ "$_am" -lt "$_bm" ] +} + +if command -v golangci-lint >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then + lint_go=$(golangci-lint --version 2>/dev/null | sed -n 's/.*built with go\([0-9][0-9.]*\).*/\1/p' | head -1) + root_go=$(go version 2>/dev/null | sed -n 's/.*go version go\([0-9][0-9.]*\).*/\1/p' | head -1) + if [ -n "$lint_go" ] && [ -n "$root_go" ] && ver_lt "$lint_go" "$root_go"; then + add FAIL toolchain "golangci-lint built with go$lint_go but GOROOT is go$root_go — lint panics on every Go repo; reinstall it under the current toolchain (infra#375)" + fi +fi + # --- output ----------------------------------------------------------------- if [ "$JSON" -eq 1 ]; then printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \ diff --git a/tests/doctor_test.sh b/tests/doctor_test.sh index 4cd16ed..c2fa15f 100644 --- a/tests/doctor_test.sh +++ b/tests/doctor_test.sh @@ -24,6 +24,18 @@ if [ ! -f "$DOCTOR" ]; then exit 1 fi +# The toolchain check (golangci-lint build-Go vs GOROOT) reads the real host +# unless we say otherwise, which made every unrelated fixture fail on a machine +# whose golangci-lint is genuinely stale. Give the whole suite a matching fake +# pair so only the cases that OPT IN exercise that check. +STUB_BIN="$WORK/_stub_bin" +mkdir -p "$STUB_BIN" +printf '#!/bin/sh\nprintf "golangci-lint has version 2.0.0 built with go1.27.0 from abc\\n"\n' > "$STUB_BIN/golangci-lint" +printf '#!/bin/sh\ncase "$1" in version) printf "go version go1.27.0 linux/amd64\\n";; *) printf "\\n";; esac\n' > "$STUB_BIN/go" +chmod +x "$STUB_BIN/golangci-lint" "$STUB_BIN/go" +PATH="$STUB_BIN:$PATH" +export PATH + cleanup() { rm -rf "$WORK"; } trap cleanup EXIT INT TERM @@ -291,5 +303,75 @@ else fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out" fi +# --- toolchain: golangci-lint must be built with Go >= GOROOT --------------- +# Measured on koala 2026-09-08 (infra#375, second recurrence): golangci-lint +# 2.11.4 built with go1.26.1 against a go1.27.0 GOROOT PANICS on any Go repo -- +# +# panic: file requires newer Go version go1.27 (application built with go1.26) +# +# and the earlier form of the same fault printed a typecheck error naming YOUR +# file, so the natural response is to debug code that is fine. It cost time in +# two repos. CI never sees it (the runner image matches), so it breaks only the +# local `task check` that AGENT.md requires before every push. +# +# The fixtures inject fake `go` and `golangci-lint` on PATH; nothing here reads +# the real host toolchain, so the suite still passes on a machine without Go. +mk_toolchain() { # $1=fixture $2=lint-build-go $3=goroot-go ("" = absent) + _b="$1/bin"; mkdir -p "$_b" + if [ -n "$2" ]; then + printf '#!/bin/sh\nprintf "golangci-lint has version 2.11.4 built with %s from abc on 2026-01-01\\n"\n' "$2" > "$_b/golangci-lint" + chmod +x "$_b/golangci-lint" + fi + if [ -n "$3" ]; then + printf '#!/bin/sh\ncase "$1" in version) printf "go version %s linux/amd64\\n";; *) printf "\\n";; esac\n' "$3" > "$_b/go" + chmod +x "$_b/go" + fi + printf '%s' "$_b" +} + +f=$(new_fixture golangci_old); wire_clean "$f" +b=$(mk_toolchain "$f" go1.26.1 go1.27.0) +_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$? +if [ "$_rc" -ne 0 ] && printf '%s' "$_out" | grep -qi 'golangci'; then + pass=$((pass + 1)); printf ' ok golangci-lint older than GOROOT is reported\n' +else + fail=$((fail + 1)); printf ' FAIL golangci-lint older than GOROOT is reported\n rc=%s got: %s\n' "$_rc" "$_out" +fi + +# Matching versions are the normal case and must stay silent. +f=$(new_fixture golangci_ok); wire_clean "$f" +b=$(mk_toolchain "$f" go1.27.0 go1.27.0) +_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$? +if [ "$_rc" -eq 0 ]; then + pass=$((pass + 1)); printf ' ok golangci-lint matching GOROOT is silent\n' +else + fail=$((fail + 1)); printf ' FAIL golangci-lint matching GOROOT is silent\n rc=%s got: %s\n' "$_rc" "$_out" +fi + +# A NEWER lint than GOROOT is fine -- the constraint is one-directional. +f=$(new_fixture golangci_newer); wire_clean "$f" +b=$(mk_toolchain "$f" go1.28.0 go1.27.0) +_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$? +if [ "$_rc" -eq 0 ]; then + pass=$((pass + 1)); printf ' ok golangci-lint newer than GOROOT is not a fault\n' +else + fail=$((fail + 1)); printf ' FAIL golangci-lint newer than GOROOT is not a fault\n rc=%s got: %s\n' "$_rc" "$_out" +fi + +# Fail OPEN on anything unparseable. doctor runs in a SessionStart hook on every +# machine; a toolchain it cannot read is not a fault it may assert. (A host with +# no Go at all takes the same path -- command -v simply misses.) +f=$(new_fixture golangci_garbled); wire_clean "$f" +_b="$f/bin"; mkdir -p "$_b" +printf '#!/bin/sh\nprintf "golangci-lint dev build\\n"\n' > "$_b/golangci-lint" +printf '#!/bin/sh\nprintf "go version devel\\n"\n' > "$_b/go" +chmod +x "$_b/golangci-lint" "$_b/go" +_out=$(PATH="$_b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$? +if [ "$_rc" -eq 0 ]; then + pass=$((pass + 1)); printf ' ok an unreadable toolchain version is not asserted as a fault\n' +else + fail=$((fail + 1)); printf ' FAIL an unreadable toolchain version is not asserted as a fault\n rc=%s got: %s\n' "$_rc" "$_out" +fi + printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ]