2222# resolving PATH without this session's env (a subprocess with a scrubbed
2323# environment, a `#!/usr/bin/env python3` script) also gets 3.12;
2424# 3. the uv-managed tools rebuilt on 3.12 — mypy and flake8 read the
25- # interpreter's version, so on 3.11 they judged code against 3.11 rules.
25+ # interpreter's version, so on 3.11 they judged code against 3.11 rules —
26+ # and then repaired, because (2) is what breaks each tool env's own
27+ # `bin/python`, and a rebuild cannot fix a link it is handed.
2628#
2729# What it deliberately does NOT touch: the update-alternatives links under
2830# /usr/bin. Scripts with a literal `#!/usr/bin/python3` shebang follow those,
@@ -344,6 +346,69 @@ retool_uv_tools() {
344346 done
345347}
346348
349+ # 3b. The link uv's rebuild cannot fix from inside.
350+ #
351+ # uv creates each tool env with `bin/python` as a SYMLINK to whatever `python3`
352+ # was at install time — here `/usr/local/bin/python3`, which leg 2 has already
353+ # replaced with a wrapper that `exec`s the session venv. Every tool env's python
354+ # then resolves its prefix to the VENV: `sys.prefix` is the venv, the tool's own
355+ # site-packages never reaches `sys.path`, and the console script dies with
356+ # `ModuleNotFoundError: No module named 'flake8'` — with flake8 sitting
357+ # installed two directories away.
358+ #
359+ # TWO paths reach that state, which is why this runs after leg 3 rather than
360+ # only on the envs leg 3 rebuilt: leg 3 rebuilds a 3.11 tool and hands the new
361+ # env the hijacked path, and leg 2 separately breaks every PRE-EXISTING tool env
362+ # that already pointed there and that leg 3 skips (`is_py312 … && continue`).
363+ # Running here covers both, because leg 2 runs before leg 3.
364+ #
365+ # Measured 2026-08-27, post-bootstrap: mypy, flake8, black, poetry and pyright
366+ # all dead this way; `ruff` survived (a native binary) and `pytest` survived
367+ # (leg 2b points its shim straight at the venv, which has pytest). The
368+ # bootstrap's `--check` called every one of them "3.12 OK", because the
369+ # interpreter they reach IS 3.12 — it is simply the wrong one. A session then
370+ # lints clean by not linting at all, and CI is the thing that finds out.
371+ #
372+ # The fix is one link: a venv's `bin/python` must resolve to a BASE interpreter,
373+ # never to a path this hook hijacks.
374+ #
375+ # This lived in `scripts/session_bootstrap.sh` for one pass, which is the wrong
376+ # home. The bootstrap is what a MULTI-repo session runs; a SINGLE-repo session
377+ # registers this hook and never calls the bootstrap, so it got leg 2's breakage
378+ # and none of the repair.
379+ repair_uv_tools () {
380+ local tools_dir base tool link prefix
381+ base=" $( " $VENV /bin/python" -c ' import sys, os; print(os.path.join(sys.base_prefix, "bin", "python3.12"))' 2> /dev/null) " || base=" "
382+ [ -x " $base " ] || base=" $( command -v python3.12 2> /dev/null) " || base=" "
383+ [ -x " $base " ] || return 0
384+ tools_dir=" ${PYAUTO_UV_TOOLS_DIR:- $(uv tool dir 2>/ dev/ null || echo " $HOME /.local/share/uv/tools" )} "
385+ [ -d " $tools_dir " ] || return 0
386+ for tool in " $tools_dir " /* /; do
387+ link=" ${tool} bin/python"
388+ [ -L " $link " ] || continue
389+ # Ask the interpreter where it thinks it lives, rather than tracing the
390+ # link: `/usr/local/bin/python3` is a WRAPPER SCRIPT (a symlink there
391+ # would lose the venv — leg 2's whole note), so `readlink -f` stops at
392+ # the wrapper and reports nothing about the venv behind it. sys.prefix
393+ # is the outcome; anything else is the mechanism.
394+ prefix=" $( " $link " -c ' import sys; print(sys.prefix)' 2> /dev/null) " || continue
395+ [ -n " $prefix " ] || continue
396+ [ " $prefix " = " ${tool%/ } " ] && continue # resolves to its own env: correct
397+ # Non-fatal like every other leg: an unwritable tools dir is a warning,
398+ # never a failed session start.
399+ ln -sfn " $base " " $link " || {
400+ log " WARNING: could not repoint $( basename " ${tool%/ } " ) at $base "
401+ continue
402+ }
403+ prefix=" $( " $link " -c ' import sys; print(sys.prefix)' 2> /dev/null) " || prefix=" "
404+ if [ " $prefix " = " ${tool%/ } " ]; then
405+ log " repointed $( basename " ${tool%/ } " ) at $base (it resolved into $VENV , not its own env)"
406+ else
407+ log " WARNING: $( basename " ${tool%/ } " ) still resolves to ${prefix:- nothing} — it will not run"
408+ fi
409+ done
410+ }
411+
347412# 4. Honest git history.
348413#
349414# A remote session clones shallow. `git merge-base --is-ancestor` then LIES
@@ -461,6 +526,15 @@ if [ "${PYAUTO_SESSION_DEFINE_ONLY:-}" = "1" ]; then
461526 return 0 2> /dev/null || exit 0
462527fi
463528
529+ # Run leg 3b on its own, without a session start. The door
530+ # `scripts/session_bootstrap.sh` knocks on after it has run every repo's hook —
531+ # a subprocess rather than a source, so this script's `set -euo pipefail` never
532+ # leaks into a caller that is contractually "a bootstrap, never a gate".
533+ if [ " ${1:- } " = " --repair-uv-tools" ]; then
534+ repair_uv_tools
535+ exit 0
536+ fi
537+
464538ensure_full_clone
465539install_workspace_settings
466540
@@ -475,6 +549,7 @@ if ensure_venv; then
475549 ensure_repo_extras
476550 point_system_default
477551 retool_uv_tools
552+ repair_uv_tools
478553 point_pytest_at_venv
479554 point_venv_scripts_at_venv
480555 # Every repo in the session registers this hook, so the second copy must not
0 commit comments