diff --git a/bin/_pybin.sh b/bin/_pybin.sh new file mode 100755 index 0000000..4e81e8f --- /dev/null +++ b/bin/_pybin.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Resolve a Python interpreter by RUNNING one β€” not by finding a name. +# +# πŸ”΄ `command -v python3` is satisfied by the Microsoft Store stub that ships on Windows: the name +# resolves, and the "interpreter" then exits 49 without running anything. Existence is not +# execution. Measured on two Windows machines (2026-08-25): the gate suite scored 34/48 on both, +# and on the second one a perfectly good `python` 3.11.15 was on PATH the whole time β€” the only +# blocking name was `python3`. The failures printed as `βœ— expected 0, got 49`, which reads like a +# verdict but is the absence of a measurement. +# +# Usage β€” source it, then use $PY unquoted (the value may carry an argument, e.g. `py -3`): +# . "$(dirname "${BASH_SOURCE[0]}")/_pybin.sh" +# PY="$(yeoul_pybin)" || yeoul_pybin_die +# $PY script.py +# +# Override with YEOUL_PYTHON if you need a specific interpreter. + +# πŸ”΄ Every gate script here shells out to Python and echoes text back through it β€” verdicts, kill +# conditions, arc titles. Python encodes stdout with the *caller's console encoding*, so on a +# console that is not UTF-8 a single non-ASCII character (an em-dash was enough) raises +# UnicodeEncodeError, kills the child, and hands the caller EMPTY output. The MCP wrapper already +# pins these for the tools it launches; the shell scripts called Python directly and bypassed it. +# Pin them here, where every script picks up the interpreter. +export PYTHONUTF8=1 +export PYTHONIOENCODING=utf-8 + +yeoul_pybin() { + local c + for c in ${YEOUL_PYTHON:+"$YEOUL_PYTHON"} python3 python "py -3"; do + # word-splitting on $c is intentional: "py -3" is a command plus an argument. + # shellcheck disable=SC2086 + if $c -c 'import sys; raise SystemExit(0)' >/dev/null 2>&1; then + printf '%s' "$c" + return 0 + fi + done + return 1 +} + +# Fail loudly. A missing interpreter must stop the run, not quietly skip the step it powers β€” +# a skipped check that reads as a pass is how this stayed invisible on Windows. +yeoul_pybin_die() { + echo "no working Python interpreter found." >&2 + echo " tried: ${YEOUL_PYTHON:+$YEOUL_PYTHON, }python3, python, py -3 β€” each by running \`-c 'import sys'\`," >&2 + echo " not by looking it up. On Windows the Microsoft Store stub answers to \`python3\` and exits 49." >&2 + echo " Install Python, or point YEOUL_PYTHON at a real interpreter." >&2 + exit 127 +} diff --git a/bin/arc-close b/bin/arc-close index 2991aea..88a5943 100755 --- a/bin/arc-close +++ b/bin/arc-close @@ -10,6 +10,10 @@ set -euo pipefail # β˜…Optional: seal the close into an append-only ledger (mirror-stack `am`, content-hash = _SUMMARY). Best-effort. # Negatives / retractions are recorded indelibly (no silent edits). +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + ARC_DIR="${1:-}"; VERDICT="${2:-}"; STOP="converged" for arg in "${@:3}"; do case "$arg" in --stop=*) STOP="${arg#--stop=}" ;; esac @@ -48,7 +52,7 @@ case "$VERDICT" in *KILL*|*kill*) IS_KILL="yes" ;; esac # NOTE: this fixes the *condition* by reference; whether the result triggers it remains a judgment. extract_kill() { # extract_kill β†’ verbatim kill_condition (whitespace-collapsed), else empty [ -f "$1" ] || return 0 - python3 - "$1" "$2" 2>/dev/null <<'PY' + $PY - "$1" "$2" 2>/dev/null <<'PY' import json, sys led, cid = sys.argv[1], sys.argv[2] for line in open(led, encoding='utf-8', errors='ignore'): @@ -127,7 +131,7 @@ check_answers() { # check_answers
# If the checker cannot separate planted violations from planted genuine answers, we do NOT # interpret the real answers β€” we die. An instrument is not trusted because it is green; # it is trusted because it just proved it can still say no. - if ! st="$(python3 "$SUBSTANCE" --selftest 2>&1)"; then + if ! st="$($PY "$SUBSTANCE" --selftest 2>&1)"; then echo "β›” seal refused: the substance checker failed its own positive control β€” the instrument is broken, so the real verdict is not interpreted." printf '%s\n' "$st" | sed 's/^/ /' exit 6 @@ -139,7 +143,7 @@ check_answers() { # check_answers
# πŸ”΄ Read the verdict from the emitted CODE, not the exit status. If the checker cannot run at # all the output is empty β€” which is not OK β€” so this **fails closed**. A false default would # turn "never measured" into "measured and fine". - vout="$(printf '%s' "$vline" | python3 "$SUBSTANCE" --label "$vlabel" 2>/dev/null || true)" + vout="$(printf '%s' "$vline" | $PY "$SUBSTANCE" --label "$vlabel" 2>/dev/null || true)" vcode="${vout%%"$TAB"*}"; vans="${vout#*"$TAB"}" [ "$vcode" = "OK" ] && continue case "$vcode" in diff --git a/bin/arc-open b/bin/arc-open index cd60e77..dc7ca85 100755 --- a/bin/arc-open +++ b/bin/arc-open @@ -5,6 +5,10 @@ set -euo pipefail # ARC/.md (deliberation thread) + tickets// + STATE.md + ROSTER.md + JOIN_PROMPTS.md + 0001_spec.md # Generic file-based deliberation engine. Runtime-agnostic. Callers pass --arcs-dir to place the arc. +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SLUG="${1:-}"; TOPIC=""; ROLES="analysis impl repro"; BACKEND="both"; ARCS_DIR=""; RELAY="orchestrator" @@ -27,8 +31,10 @@ case "$BACKEND" in a|b|both) ;; *) echo "backend must be a|b|both"; exit 1 ;; es # ── Gate-1: prior-art / closed-question check (optional; warn-only, non-blocking; skipped if no registry) ── REGISTRY="${YEOUL_CLOSED_REGISTRY:-$SCRIPT_DIR/../registry/closed_questions.jsonl}" -if command -v python3 >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/closed_check.py" ] && [ -f "$REGISTRY" ]; then - G1WARN="$(python3 "$SCRIPT_DIR/closed_check.py" "$TOPIC" "$REGISTRY" 2>/dev/null || true)" +# no interpreter name-check here: $PY was resolved by running one, and a run with no working +# interpreter has already stopped at yeoul_pybin_die. +if [ -f "$SCRIPT_DIR/closed_check.py" ] && [ -f "$REGISTRY" ]; then + G1WARN="$($PY "$SCRIPT_DIR/closed_check.py" "$TOPIC" "$REGISTRY" 2>/dev/null || true)" if [ -n "$G1WARN" ]; then echo "── ⚠️ Gate-1 closed-question match ──" echo "$G1WARN" diff --git a/bin/arc-prereg b/bin/arc-prereg index 49348c0..4c4769f 100755 --- a/bin/arc-prereg +++ b/bin/arc-prereg @@ -8,6 +8,10 @@ set -euo pipefail # # Typical flow: seal the kill-condition with mirror-stack (mm_preregister) β†’ arc-prereg . +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + ARC_DIR="${1:-}"; CLAIM="${2:-}"; LEDGER="${3:-${YEOUL_LEDGER:-}}" if [ -z "$ARC_DIR" ] || [ -z "$CLAIM" ]; then echo "usage: arc-prereg [ledger] (ledger defaults to \$YEOUL_LEDGER)"; exit 1 @@ -16,7 +20,7 @@ fi [ -n "$LEDGER" ] || { echo "no ledger given (pass one or set \$YEOUL_LEDGER)"; exit 1; } [ -f "$LEDGER" ] || { echo "ledger not found: $LEDGER"; exit 1; } -KILL="$(python3 - "$LEDGER" "$CLAIM" 2>/dev/null <<'PY' +KILL="$($PY - "$LEDGER" "$CLAIM" 2>/dev/null <<'PY' import json, sys led, cid = sys.argv[1], sys.argv[2] for line in open(led, encoding='utf-8', errors='ignore'): diff --git a/bin/index-append b/bin/index-append index 3b89a2f..6f5d1e1 100755 --- a/bin/index-append +++ b/bin/index-append @@ -6,6 +6,10 @@ set -uo pipefail # (never rots; not a new store). Index path = $YEOUL_INDEX, default /KNOWLEDGE_INDEX.md. # Best-effort: a failure here never affects the close. +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ARC_DIR="${1:-}"; [ -n "$ARC_DIR" ] && [ -d "$ARC_DIR" ] || exit 0 ARC="$(basename "$ARC_DIR")" @@ -14,7 +18,7 @@ INDEX="${YEOUL_INDEX:-$SCRIPT_DIR/../KNOWLEDGE_INDEX.md}" CLAIM="-"; [ -f "$ARC_DIR/.prereg" ] && CLAIM="$(sed -n '1p' "$ARC_DIR/.prereg")" -read -r DATE STOP < <(python3 - "$SUM" <<'PY' +read -r DATE STOP < <($PY - "$SUM" <<'PY' import re,sys t=open(sys.argv[1],encoding='utf-8',errors='ignore').read() def g(p,d='-'): @@ -22,7 +26,7 @@ def g(p,d='-'): print(g(r'\*\*Closed\*\*:\s*([0-9-]+)'), g(r'\*\*stop_reason\*\*:\s*(\S+)')) PY ) -VERDICT="$(python3 - "$SUM" <<'PY' +VERDICT="$($PY - "$SUM" <<'PY' import re,sys t=open(sys.argv[1],encoding='utf-8',errors='ignore').read() m=re.search(r'\*\*Verdict\*\*:\s*(.+)',t); print((m.group(1).strip() if m else '-')[:160]) @@ -31,7 +35,7 @@ PY # The old regex assumed the first item was a `- ` bullet, so a numbered list (`1. …`) matched # nothing β†’ '-'. The close still succeeded and the log still printed "appended" β€” the failure # was invisible. Take the section's first substantive line and strip only the list marker. -CLOSED="$(python3 - "$SUM" <<'PY' +CLOSED="$($PY - "$SUM" <<'PY' import re,sys t=open(sys.argv[1],encoding='utf-8',errors='ignore').read() m=re.search(r'##\s*What was closed[^\n]*\n(.*?)(?=\n##\s|\Z)', t, re.S) diff --git a/bin/ralph b/bin/ralph index 4140807..d1373b7 100755 --- a/bin/ralph +++ b/bin/ralph @@ -11,6 +11,10 @@ set -euo pipefail # ⚠️ Loop-forbidden (belongs to the human/session): measurement runs, sealing, PASS/KILL judgment. # Items without a `verify:` command are refused (exit 3). +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECTS_DIR="${YEOUL_PROJECTS:-./projects}" @@ -76,8 +80,8 @@ $(cat "$TODO") "$SCRIPT_DIR/verify-gate" "$TODO" --revert --require-verify || echo " ⚠ harness reverted a checked item (verify failed on re-run, or its verify clause was missing)" TOK=0 - if command -v python3 >/dev/null 2>&1; then - TOK=$(python3 -c " + if [ -n "$PY" ]; then + TOK=$($PY -c " import json,sys try: d=json.load(open('$OUT')); u=d.get('usage',{}) diff --git a/bin/status b/bin/status index 15ad63c..f1e686d 100755 --- a/bin/status +++ b/bin/status @@ -4,6 +4,10 @@ set -euo pipefail # One line per active project (path without _archive): name Β· latest arc verdict Β· dev TODO progress. # Projects root ./projects (override YEOUL_PROJECTS). +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + PROJECTS_DIR="${YEOUL_PROJECTS:-./projects}" MD=0 for a in "$@"; do case "$a" in --md) MD=1 ;; esac; done @@ -11,9 +15,9 @@ for a in "$@"; do case "$a" in --md) MD=1 ;; esac; done [ -d "$PROJECTS_DIR" ] || { echo "no projects dir: $PROJECTS_DIR"; exit 0; } # truncate to 100 chars on a codepoint boundary (never splits a multibyte char). -# python3 is already a project dependency; if absent, degrade to no truncation (never mojibake). +# $PY is already a project dependency; if absent, degrade to no truncation (never mojibake). _trunc100() { - python3 -c 'import sys; s=sys.stdin.read().strip(); print(s[:100]+("…" if len(s)>100 else ""))' 2>/dev/null || cat + $PY -c 'import sys; s=sys.stdin.read().strip(); print(s[:100]+("…" if len(s)>100 else ""))' 2>/dev/null || cat } verdict_of() { local proj="$1" diff --git a/bin/substance_check.py b/bin/substance_check.py index 7e69718..0fd1d81 100755 --- a/bin/substance_check.py +++ b/bin/substance_check.py @@ -293,10 +293,47 @@ def selftest(verbose=False): return total - len(fails), total, fails +def emit(code, ans): + """The verdict channel. ASCII code, TAB, answer β€” written as UTF-8 BYTES. + + πŸ”΄ This used to be `sys.stdout.write(...)`, whose encoding is the caller's console encoding. On a + console that is not UTF-8, echoing back an answer containing any non-ASCII character (an + em-dash was enough) raised UnicodeEncodeError, killed the process, and left stdout EMPTY. The + gate reads its verdict from this output and correctly refuses on empty ("an unmeasured field is + not a passing field") β€” so a genuine answer was refused, on one machine and not another, and + nothing in the refusal pointed at encoding. Measured on Windows 2026-08-25; reproduced with + PYTHONIOENCODING=ascii. The contract is bytes, so it cannot depend on where it is being read. + """ + sys.stdout.buffer.write((code + "\t" + ans).encode("utf-8", "replace")) + sys.stdout.buffer.flush() + + def main(argv): if "--selftest" in argv: ok, total, fails = selftest(verbose=True) # πŸ”΄ Print the DENOMINATOR. A checker that measured nothing also prints green. + # πŸ”΄ Echo a non-ASCII answer through the real output path before declaring the checker sound. + # The selftest prints an ASCII-only summary, so it scored 27/27 on a machine where every + # real call carrying an em-dash died writing its result. A positive control that never + # exercises the path under test vouches for nothing. + # πŸ”΄ through emit(), the SAME function the real call uses. An earlier version of this + # control wrote the probe with sys.stdout.buffer directly β€” it therefore vouched for a + # path the product does not take, and stayed green with the defect reinstated. + probe = "em-dash \u2014 and hangul \uac00 must survive the verdict channel" + try: + emit("SELFTEST_ECHO", probe) + sys.stdout.buffer.write(b"\n") + sys.stdout.buffer.flush() + except Exception as e: # pragma: no cover - the failure this exists to catch + # πŸ”΄ report on stderr, in pure ASCII. The first version used print(... %r) β€” repr of a + # UnicodeEncodeError contains the offending character, sent through the very channel + # that just failed, so the diagnostic died while reporting the fault it exists to + # report. A failure message must not depend on what it is reporting about. + msg = ("substance_check selftest: FAILED to write a non-ASCII verdict: %s\n" + % type(e).__name__) + sys.stderr.buffer.write(msg.encode("ascii", "replace")) + sys.stderr.buffer.flush() + return 9 print("substance_check selftest: %d/%d (violations %d - genuine %d - raw %d)" % (ok, total, len(_PLANT_VIOLATIONS), len(_PLANT_GENUINE), len(_PLANT_RAW))) return 0 if not fails else 9 @@ -306,7 +343,7 @@ def main(argv): code, ans = extract(sys.stdin.buffer.read()) if code == "OK": code, _ = judge(label, ans) - sys.stdout.write(code + "\t" + ans) + emit(code, ans) return 0 if code == "OK" else 1 diff --git a/bin/verify-gate b/bin/verify-gate index fe8c776..8572ce2 100755 --- a/bin/verify-gate +++ b/bin/verify-gate @@ -13,6 +13,10 @@ set -uo pipefail # has already established that every item in a loop-driven TODO carries a verify command. # Default stays off so a mixed TODO (loop items + manual items) can still be scanned standalone. +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_pybin.sh" +PY="$(yeoul_pybin)" || yeoul_pybin_die + TODO="${1:-}"; REVERT=0; REQUIRE=0 for a in "${@:2}"; do case "$a" in --revert) REVERT=1 ;; @@ -37,7 +41,7 @@ while IFS= read -r line || [ -n "$line" ]; do elif printf '%s' "$line" | grep -qE '^- \[x\].*verify:[[:space:]]*`[^`]+`'; then # extract the FIRST verify block via python (sed's greedy `.*verify:` grabbed the LAST block, # letting an appended decoy `verify: `true`` override a real failing command). - cmd="$(printf '%s' "$line" | python3 -c 'import sys,re; m=re.search(r"verify:\s*`([^`]+)`", sys.stdin.read()); sys.stdout.write(m.group(1) if m else "")')" + cmd="$(printf '%s' "$line" | $PY -c 'import sys,re; m=re.search(r"verify:\s*`([^`]+)`", sys.stdin.read()); sys.stdout.write(m.group(1) if m else "")')" if ! bash -c "$cmd" >/dev/null 2>&1; then FAIL=1 short="$(printf '%s' "$line" | sed -E 's/^(- \[x\][^:]{0,50}).*/\1/')" diff --git a/setup/pre-publish-check.sh b/setup/pre-publish-check.sh index 7b2af1f..70ec92e 100755 --- a/setup/pre-publish-check.sh +++ b/setup/pre-publish-check.sh @@ -6,6 +6,10 @@ set -uo pipefail # 3) empty-scaffolding guard (a runnable worked example must exist) # Exit 0 = clean Β· non-zero = issues found (do not publish yet). +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" && pwd)"/_pybin.sh +PY="$(yeoul_pybin)" || yeoul_pybin_die + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$(cd "$SCRIPT_DIR/.." && pwd)" FAIL=0 @@ -29,7 +33,7 @@ echo "── 1) personalization leak scan ──" # (b) private absolute paths (/home/... or /data/...) that must not ship. # Intentional localizations are allowed (README_KO.md, *.ko.md, docs/ko/); Hangul anywhere else is a leak. # Hangul detection via python (portable β€” GNU grep's -P is unavailable on macOS/BSD). -# NB: the file list goes through a temp file, not a pipe. `python3 - < "$FILELIST" @@ -41,7 +45,7 @@ if [ "${SCANNED:-0}" -eq 0 ]; then echo " βœ— scanned 0 files β€” an empty scan is a failure, not a pass" FAIL=1 fi -HANGUL="$(python3 - "$REPO" "$FILELIST" <<'PY' +HANGUL="$($PY - "$REPO" "$FILELIST" <<'PY' import os, re, sys root = sys.argv[1]; h = re.compile('[\uac00-\ud7a3]') # Hangul syllables (escaped β†’ this file stays Hangul-free) rels = [x.rstrip('\n') for x in open(sys.argv[2], encoding='utf-8') if x.strip()] diff --git a/tests/test_gates.sh b/tests/test_gates.sh index 76da5f3..b5747d2 100755 --- a/tests/test_gates.sh +++ b/tests/test_gates.sh @@ -6,6 +6,10 @@ set -uo pipefail # 2. ralph verify-gate: unchecked item without `verify:` is refused (3) # Exit 0 = all gates behaved as specified. +# Resolve the interpreter by running one β€” `command -v python3` also finds the Windows Store stub. +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" && pwd)"/_pybin.sh +PY="$(yeoul_pybin)" || yeoul_pybin_die + BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" && pwd)" WS="$(mktemp -d)"; trap 'rm -rf "$WS"' EXIT cd "$WS"; export YEOUL_PROJECTS="$WS/projects" @@ -189,7 +193,7 @@ EARC="$(ls -d "$WS"/arcs/*_enc)" ESUM="$(ls "$EARC"/_SUMMARY_*.md)" sedi 's/- (fill in)/- concrete conclusion here/' "$ESUM" sedi 's/(unfilled)/yes/g' "$ESUM" -python3 - "$ESUM" <<'PYDAMAGE' +$PY - "$ESUM" <<'PYDAMAGE' import sys p = sys.argv[1] out = [] @@ -222,8 +226,8 @@ esac # 28 of 28 evasive answers sealed. These assertions pin the class in BOTH directions β€” a # repair that only tightened would pass the top half and quietly reject real answers. SUBSTANCE="$BIN/substance_check.py" -python3 "$SUBSTANCE" --selftest >/dev/null 2>&1; assert "substance checker passes its own positive control" 0 $? -echo " $(python3 "$SUBSTANCE" --selftest 2>&1 | tail -1)" # πŸ”΄ print the denominator, not just green +$PY "$SUBSTANCE" --selftest >/dev/null 2>&1; assert "substance checker passes its own positive control" 0 $? +echo " $($PY "$SUBSTANCE" --selftest 2>&1 | tail -1)" # πŸ”΄ print the denominator, not just green subst_case() { # subst_case "$BIN/arc-open" sc --topic="substance" --arcs-dir="$WS/arcs" >/dev/null 2>&1 @@ -232,7 +236,7 @@ subst_case() { # subst_case local S; S="$(ls "$A"/_SUMMARY_*.md)" sedi 's/- (fill in)/- concrete conclusion here/' "$S" # anchor/catalog have their own branches; drive the general branch with the candidate - python3 - "$S" "$2" <<'PYFILL' + $PY - "$S" "$2" <<'PYFILL' import sys, re p, ans = sys.argv[1], sys.argv[2] out = [] @@ -267,7 +271,7 @@ subst_case "genuine answer carrying a deferral CLAUSE still seals" \ # A documented fallback is untested code. Sabotage the checker so it can never say no, then # assert the gate REFUSES TO INTERPRET (exit 6) instead of sealing an evasive answer. SBIN="$(mktemp -d)"; cp "$BIN"/* "$SBIN/" 2>/dev/null -python3 - "$SBIN/substance_check.py" <<'PYSAB' +$PY - "$SBIN/substance_check.py" <<'PYSAB' import sys p = sys.argv[1]; s = open(p, encoding="utf-8").read() i = s.index("def judge(label, ans):") @@ -275,7 +279,7 @@ s = s[:i] + 'def judge(label, ans):\n return "OK", ans\n\ndef _judge_disabled open(p, "w", encoding="utf-8").write(s) PYSAB # the sabotage must actually have landed, or every assertion below passes vacuously -python3 "$SBIN/substance_check.py" --selftest >/dev/null 2>&1 \ +$PY "$SBIN/substance_check.py" --selftest >/dev/null 2>&1 \ && { echo " βœ— sabotage did NOT land β€” the checks below would pass for the wrong reason"; FAIL=1; } \ || echo " βœ“ sabotage landed (checker can no longer fail a planted violation)" "$SBIN/arc-open" sb --topic="sabotage" --arcs-dir="$WS/arcs" >/dev/null 2>&1 @@ -374,6 +378,111 @@ if grep -rq 'install mirror-stack for sealing' "$BIN"/arc-close "$BIN"/close-pro bad "[YL-09] seal message still blames installation for a PATH test" else ok "[YL-09] seal message names the tested condition (\`am\` on PATH), not an assumed cause"; fi +# ── interpreter resolution: a name that resolves is not an interpreter that runs ────────────── +# Windows ships a Microsoft Store stub that answers to `python3` and exits 49 without running +# anything. `command -v python3` is satisfied by it, so the whole gate suite scored 34/48 on two +# Windows machines β€” printed as `βœ— expected 0, got 49`, which reads like a verdict and is actually +# the absence of a measurement. Reproduced here with a stub, so this is testable off Windows. +echo +echo "── interpreter resolution ──" +# Say which interpreter this run actually used. Without it a failure report from another machine +# cannot distinguish "resolved a different interpreter" from "the check itself is broken there". +# πŸ”΄ print the PATH too, not just the name and version. Two machines reported different results +# with the same name and the same version β€” the interpreter each had resolved was a *different +# venv* that happened to sit ahead on PATH, and the name+version line could not show that. +echo " β„Ή resolved: $PY -> $($PY -c 'import sys,platform; print(sys.executable, platform.python_version(), sys.platform)' 2>&1 | head -1)" +STUBD="$WS/stub"; mkdir -p "$STUBD" +mkstub() { printf '#!/usr/bin/env bash\necho "Python" >&2\nexit 49\n' > "$STUBD/$1"; chmod +x "$STUBD/$1"; } + +mkstub python3 +if ( PATH="$STUBD:$PATH"; . "$BIN/_pybin.sh"; [ "$(yeoul_pybin)" != "python3" ] ) 2>/dev/null; then + ok "[PY-01] a stub that answers to the name is rejected (resolved by running, not by lookup)" +else bad "[PY-01] the stub was accepted as an interpreter"; fi + +# the second Windows machine had a perfectly good `python` on PATH the whole time +# πŸ”΄ find the real interpreter by RUNNING candidates. `command -v python3` here would hand back +# the stub when this suite is itself run under a stubbed PATH β€” using a name lookup to locate a +# real interpreter, inside the test for that exact bug. +REALPY="" +for c in "$(command -v python3)" "$(command -v python)" /usr/bin/python3 /usr/bin/python; do + [ -n "$c" ] && "$c" -c 'raise SystemExit(0)' >/dev/null 2>&1 && { REALPY="$c"; break; } +done +# πŸ”΄ a wrapper, not a symlink. `ln -s` is the only symlink this suite would use, and MSYS/Git Bash +# copies the target instead of linking unless winsymlinks is set β€” copying a Windows python.exe +# produces a broken standalone, so this check would fail for a reason that has nothing to do with +# what it is testing. +printf '#!/usr/bin/env bash\nexec "%s" "$@"\n' "$REALPY" > "$STUBD/python"; chmod +x "$STUBD/python" +if ( PATH="$STUBD:$PATH"; . "$BIN/_pybin.sh"; P="$(yeoul_pybin)"; $P -c 'raise SystemExit(0)' ) 2>/dev/null; then + ok "[PY-02] falls through to a working interpreter under another name" +else bad "[PY-02] did not find the working interpreter that was on PATH"; fi + +# and when nothing runs, it must stop the run β€” not skip the step and let the skip read as a pass. +# πŸ”΄ This block must first ESTABLISH "nothing runs", and then check that it was established. The +# earlier version asserted it: it stubbed python3/python/py and assumed that covered every +# candidate. It did not β€” $YEOUL_PYTHON is tried first and is not a PATH name at all, and on +# Windows the .exe forms are separate files. With an interpreter still reachable the resolver +# correctly succeeded and these two checks failed, blaming the product for the test's own gap. +# (Reproduced by running the suite with YEOUL_PYTHON set: 50/52, the same score reported from a +# Windows machine.) A precondition that is assumed instead of measured is the defect this whole +# suite is about, so it is now measured β€” and if it cannot be met the checks report inconclusive +# rather than passing or failing, because neither verdict would mean anything. +rm -f "$STUBD/python" +for n in python3 python py python3.exe python.exe py.exe; do mkstub "$n"; done +if ( unset YEOUL_PYTHON; PATH="$STUBD:$PATH"; . "$BIN/_pybin.sh"; yeoul_pybin >/dev/null 2>&1 ); then + CHECKS=$((CHECKS+2)) + echo " ? [PY-03] could not establish 'no interpreter reachable' β€” an interpreter survived the" + echo " stubbing, so neither PY-03 check is evidence here. Not counted as a pass." + FAIL=1 +else + ok "[PY-03] no working interpreter => resolution fails" + OUT="$( unset YEOUL_PYTHON; PATH="$STUBD:$PATH" bash "$BIN/status" 2>&1 )"; RC=$? + if [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q 'no working Python interpreter'; then + ok "[PY-03] a script stops with a named cause (exit $RC), it does not skip silently" + else bad "[PY-03] script exited $RC without naming the missing interpreter"; fi +fi +rm -rf "$STUBD" + +# ── the verdict channel must not depend on the console encoding ─────────────────────────────── +# Measured on Windows 2026-08-25: two machines, same PR. On one, two checks failed with +# `could not run the substance checker` β€” the checker had finished judging and then died *writing +# its result back*, because the answer contained an em-dash and stdout was cp949. stdout came back +# empty, the gate refused (correctly: an unmeasured field is not a passing field), and a genuine +# answer was rejected with nothing in the refusal pointing at encoding. +# +# πŸ”΄ The second machine passed, and that green was a coincidence: cp1252 happens to contain the +# em-dash, cp949 does not. Neither contains Hangul, and our summaries are written in Korean β€” so +# both machines carry the same defect and only one showed it. Testing one synthetic encoding +# would repeat the mistake, so this runs the two real code pages and asserts on a payload built +# to be lethal to both: U+2014 (absent from cp949) and U+AC00 (absent from cp1252). +echo +echo "── verdict channel encoding ──" +# built from escapes on purpose: a literal Hangul sample would be a personalization leak in an +# English-only repo and setup/pre-publish-check.sh rejects it, correctly. U+2014 em-dash (absent +# from cp949) and U+AC00 (absent from cp1252) β€” one character for each machine's code page. +NONASCII="$(printf '\u2014 \uac00')" +ENCLINE="- **Sealed-condition cross-check**: converged ${NONASCII} design settled, non-ascii included" +TAB_="$(printf '\t')" +for CP in ascii cp949 cp1252; do + EOUT="$( printf '%s' "$ENCLINE" | env -u PYTHONUTF8 PYTHONIOENCODING="$CP" \ + $PY "$BIN/substance_check.py" --label 'Sealed-condition cross-check' 2>/dev/null )" + if [ -n "$EOUT" ] && [ "${EOUT%%"$TAB_"*}" = "OK" ]; then + ok "[ENC-01/$CP] a non-ASCII answer still returns its verdict" + else bad "[ENC-01/$CP] verdict channel produced [$EOUT]"; fi + # πŸ”΄ surviving is not enough β€” the answer must come back INTACT. Pinning the stream with + # errors="replace" would keep the code alive and hand back an answer full of `?`: the gate + # would then judge, report, and quote mangled evidence. Writing UTF-8 bytes to stdout.buffer + # sidesteps the console code page entirely, so this asserts the characters are still there. + if printf '%s' "${EOUT#*"$TAB_"}" | grep -qF "$NONASCII"; then + ok "[ENC-02/$CP] the answer round-trips intact, not replaced with '?'" + else bad "[ENC-02/$CP] answer came back mangled: [${EOUT#*"$TAB_"}]"; fi + # and the checker's own positive control has to run through that same channel, or it vouches for + # nothing β€” it scored 27/27 on the machine where every real call carrying an em-dash was dying. + ESELF="$( env -u PYTHONUTF8 PYTHONIOENCODING="$CP" $PY "$BIN/substance_check.py" --selftest 2>&1 )" + if printf '%s' "$ESELF" | grep -q 'selftest: [0-9]*/[0-9]*'; then + ok "[ENC-03/$CP] the checker's positive control passes through the channel it vouches for" + else bad "[ENC-03/$CP] selftest did not survive: $(printf '%s' "$ESELF" | tail -1)"; fi +done + echo if [ "$CHECKS" -eq 0 ]; then echo "β›” 0/0 β€” no checks were collected; an empty run is a failure, not a pass"