From 5232b3abb09088ccded17bd3a42c363b85aec356 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 1 Sep 2026 11:21:32 -0700 Subject: [PATCH 1/3] Probe for the Interpreter in the Order the Spec Declares, and Enforce Its Floor Both hooks said the interpreter is chosen "by running the probes spec/host-tools.json declares, in its order", then probed python3 before py -3 while the spec declares py -3 first. The spec's order is deliberate and carries its reason: a bare python3 is reached through PATH, so on Windows an activated virtual environment or an interpreter from MSYS2, Cygwin or Scoop answers ahead of the managed one and would be graded in its place, while the py launcher reaches a registered interpreter whatever is active and exists on Windows alone. The hooks now probe in that order and say why, and the pre-commit comment loses the claim that Windows registers no python3, which is no longer true. The pre-push hook also enforces the 3.13 floor the spec declares rather than accepting any interpreter whose --version runs. The engines import datetime.UTC at module level, so an older interpreter fails at import and exits 1, which reads as a gate refusal rather than as the gate never running, the one distinction the refusal table exists to keep. An unparseable version refuses for the same reason. The comparison is sort -V rather than a string test, checked across the cases that separate the two: 3.13.5, 3.13.0, 3.13, 3.14.1 and 4.0 pass, 3.12.9, 3.9.6 and 3.2 refuse, and an empty or unrecognized line refuses. --- .husky/pre-commit | 15 +++++++-------- .husky/pre-push | 28 ++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index fe676efb..a65e8281 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -25,16 +25,15 @@ uvx ruff@latest check . uvx mypy@latest # The interpreter is chosen by running the probes spec/host-tools.json declares, in its order. -# On native Windows the python.org install registers `py` and not `python3`. -# That name resolves to a Microsoft Store alias stub, and Git Bash inherits the Windows PATH. -# The stub is on PATH and fails when run, so a presence test selects it and the hook then breaks. -# Running the probe is the whole point: it is what tells a working interpreter from a name. -if python3 --version >/dev/null 2>&1; then - run_py() { python3 "$@"; } -elif py -3 --version >/dev/null 2>&1; then +# Running the probe is the whole point: it is what tells a working interpreter from a name, and Git Bash inherits a Windows PATH that can carry a name which fails when run. +# The py launcher answers first because it reaches a registered interpreter whatever is active, where a bare python3 is reached through PATH and an activated virtual environment answers there instead. +# That launcher exists on Windows alone, so the second probe is what answers on Linux and macOS. +if py -3 --version >/dev/null 2>&1; then run_py() { py -3 "$@"; } +elif python3 --version >/dev/null 2>&1; then + run_py() { python3 "$@"; } else - echo "pre-commit: neither 'python3 --version' nor 'py -3 --version' ran, so the doc gates did not run." >&2 + echo "pre-commit: neither 'py -3 --version' nor 'python3 --version' ran, so the doc gates did not run." >&2 echo "pre-commit: see docs/host-setup.md 'What a Host Must Provide'." >&2 exit 1 fi diff --git a/.husky/pre-push b/.husky/pre-push index 4f0be8cc..699d80fb 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -64,15 +64,31 @@ elif [ "$tree_status" -ne 0 ]; then fi # The interpreter is chosen by running the probes spec/host-tools.json declares, in its order. -# The pre-commit hook states why a presence test picks the wrong name on native Windows. -if python3 --version >/dev/null 2>&1; then - run_py() { python3 "$@"; } - py_name=python3 -elif py -3 --version >/dev/null 2>&1; then +# The pre-commit hook states why running a probe rather than testing for a name is the point. +# The py launcher answers first because it reaches a registered interpreter whatever is active, where a bare python3 is reached through PATH and an activated virtual environment answers there instead. +# That launcher exists on Windows alone, so the second probe is what answers on Linux and macOS. +if py -3 --version >/dev/null 2>&1; then run_py() { py -3 "$@"; } py_name="py -3" +elif python3 --version >/dev/null 2>&1; then + run_py() { python3 "$@"; } + py_name=python3 else - echo "pre-push: neither 'python3 --version' nor 'py -3 --version' ran, so the review gate did not run." >&2 + echo "pre-push: neither 'py -3 --version' nor 'python3 --version' ran, so the review gate did not run." >&2 + echo "pre-push: see docs/host-setup.md 'What a Host Must Provide'." >&2 + exit 1 +fi + +# The engines import datetime.UTC at module level, so an interpreter below the floor fails at import and exits 1, which reads as a refusal rather than as the gate never running. +# The floor and the version pattern are spec/host-tools.json's, read here rather than restated. +py_floor=3.13 +py_version=$(run_py --version 2>&1 | sed -n 's/^Python \([0-9][0-9.]*\).*/\1/p') +if [ -z "$py_version" ]; then + echo "pre-push: '$py_name --version' printed no recognizable version, so the review gate did not run." >&2 + exit 1 +fi +if [ "$(printf '%s\n%s\n' "$py_floor" "$py_version" | sort -V | head -n 1)" != "$py_floor" ]; then + echo "pre-push: '$py_name' is Python $py_version, below the $py_floor floor, so the review gate did not run." >&2 echo "pre-push: see docs/host-setup.md 'What a Host Must Provide'." >&2 exit 1 fi From 187126b8f43b1287cc5341a08009311804526795 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 1 Sep 2026 11:31:19 -0700 Subject: [PATCH 2/3] Let the Interpreter Answer for Its Own Version, Rather Than sort -V The floor check compared version strings through sort -V piped into head, which carried two defects the reviewers caught in my own new code. sort -V is a GNU spelling. This repository uses it in host-setup/linux scripts alone, and macOS is a first-class platform here, so a hook is the wrong place to depend on it. A failed pipeline would also have yielded no matching floor, which takes the below-floor branch and refuses a push on a host that satisfies the floor. Piping sort into head -n 1 under set -Eeuo pipefail is the early-reader pattern the shell rules name. Measured here: harmless at two lines, and exit 141 once the producer cannot finish writing before the reader leaves. Both go away by asking the interpreter about itself, which needs no text comparison and no pipeline. Verified across the version_info values that separate the two arms: 3.13, 3.13.0, 3.13.5, 3.14.1 and 4.0.0 pass, and 3.12.9, 3.9.6, 3.2.0 and 2.7.18 refuse while still reporting the version they found. The probe comments come down to the one line each that carries a reason the code does not. --- .husky/pre-commit | 4 +--- .husky/pre-push | 16 +++++----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index a65e8281..c29f8a39 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -25,9 +25,7 @@ uvx ruff@latest check . uvx mypy@latest # The interpreter is chosen by running the probes spec/host-tools.json declares, in its order. -# Running the probe is the whole point: it is what tells a working interpreter from a name, and Git Bash inherits a Windows PATH that can carry a name which fails when run. -# The py launcher answers first because it reaches a registered interpreter whatever is active, where a bare python3 is reached through PATH and an activated virtual environment answers there instead. -# That launcher exists on Windows alone, so the second probe is what answers on Linux and macOS. +# Running one rather than testing for a name is the point, since Git Bash inherits a Windows PATH that can carry a name which fails when run. if py -3 --version >/dev/null 2>&1; then run_py() { py -3 "$@"; } elif python3 --version >/dev/null 2>&1; then diff --git a/.husky/pre-push b/.husky/pre-push index 699d80fb..ed5e7ee0 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -64,9 +64,7 @@ elif [ "$tree_status" -ne 0 ]; then fi # The interpreter is chosen by running the probes spec/host-tools.json declares, in its order. -# The pre-commit hook states why running a probe rather than testing for a name is the point. -# The py launcher answers first because it reaches a registered interpreter whatever is active, where a bare python3 is reached through PATH and an activated virtual environment answers there instead. -# That launcher exists on Windows alone, so the second probe is what answers on Linux and macOS. +# The py launcher answers first because it reaches a registered interpreter whatever is active, where a bare python3 is reached through PATH and a virtual environment can answer there instead. if py -3 --version >/dev/null 2>&1; then run_py() { py -3 "$@"; } py_name="py -3" @@ -80,15 +78,11 @@ else fi # The engines import datetime.UTC at module level, so an interpreter below the floor fails at import and exits 1, which reads as a refusal rather than as the gate never running. -# The floor and the version pattern are spec/host-tools.json's, read here rather than restated. +# The interpreter answers about its own version, which needs no text comparison and so no sort -V, a GNU spelling this repository uses only in Linux-only scripts. py_floor=3.13 -py_version=$(run_py --version 2>&1 | sed -n 's/^Python \([0-9][0-9.]*\).*/\1/p') -if [ -z "$py_version" ]; then - echo "pre-push: '$py_name --version' printed no recognizable version, so the review gate did not run." >&2 - exit 1 -fi -if [ "$(printf '%s\n%s\n' "$py_floor" "$py_version" | sort -V | head -n 1)" != "$py_floor" ]; then - echo "pre-push: '$py_name' is Python $py_version, below the $py_floor floor, so the review gate did not run." >&2 +if ! run_py -c "import sys; raise SystemExit(0 if sys.version_info >= tuple(int(n) for n in '$py_floor'.split('.')) else 1)"; then + py_version=$(run_py -c "import sys; print('.'.join(str(n) for n in sys.version_info[:3]))" 2>/dev/null || true) + echo "pre-push: '$py_name' is Python ${py_version:-of an unreadable version}, below the $py_floor floor, so the review gate did not run." >&2 echo "pre-push: see docs/host-setup.md 'What a Host Must Provide'." >&2 exit 1 fi From 8d54bd500f5add18975845cb209ab6b16a4a8091 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 1 Sep 2026 11:39:58 -0700 Subject: [PATCH 3/3] Read the Floor Out of the Spec Rather Than Repeating It The floor was a literal in the hook, which is the shape that produced the defect this branch exists to fix: the probe order lived in two places, the spec moved, and the hook did not. A floor written twice drifts the same way. The hook reads spec/host-tools.json for the python3 minimum with the interpreter it just selected. A spec it cannot read refuses the push and says so, rather than falling through to an unchecked interpreter, since a floor that cannot be read is the gate not running rather than a version verdict. --- .husky/pre-push | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.husky/pre-push b/.husky/pre-push index ed5e7ee0..b2a77a8c 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -79,7 +79,12 @@ fi # The engines import datetime.UTC at module level, so an interpreter below the floor fails at import and exits 1, which reads as a refusal rather than as the gate never running. # The interpreter answers about its own version, which needs no text comparison and so no sort -V, a GNU spelling this repository uses only in Linux-only scripts. -py_floor=3.13 +# The floor is read out of the spec rather than repeated here, since a copy drifts the way the probe order above already did. +py_floor=$(run_py -c "import json; print(next(t['minimum'] for t in json.load(open('spec/host-tools.json'))['tools'] if t['name'] == 'python3'))" 2>/dev/null) || py_floor="" +if [ -z "$py_floor" ]; then + echo "pre-push: could not read the python3 floor from spec/host-tools.json, so the review gate did not run." >&2 + exit 1 +fi if ! run_py -c "import sys; raise SystemExit(0 if sys.version_info >= tuple(int(n) for n in '$py_floor'.split('.')) else 1)"; then py_version=$(run_py -c "import sys; print('.'.join(str(n) for n in sys.version_info[:3]))" 2>/dev/null || true) echo "pre-push: '$py_name' is Python ${py_version:-of an unreadable version}, below the $py_floor floor, so the review gate did not run." >&2