From f780d023bee6889b826b4a4c755629ab49f33e53 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 29 Aug 2026 07:59:15 +0200 Subject: [PATCH] A run may no longer change the tree it ran in Executing coursework has silently overwritten a submitted deliverable twice in this campaign -- TRAK's outputs/output.png and SPD's results_*.png -- and both times it was found by a later `git status`, not by the run. `course_main` now snapshots what git sees under the course root before and after `_run`, and fails with a diff naming the files if anything moved. Gitignored build output does not show up there, which is why every runner already writes into build/. Seen failing before being trusted: a stub whose `_run` appends a newline to TRAK's config.ini exits 1 and names the file; the same stub without the append exits 0; and a real `./run.sh run Programming/TRAK` still exits 0 after rendering, because it writes into build/. The notebook branch was the specific landmine. `nbconvert --execute --inplace` rewrites the notebook it just ran, and all four notebooks here are tracked with their outputs. It now executes to a temp --output-dir. That alone is not enough, though: nbconvert runs the kernel with its cwd set to the notebook's own directory, so a cell calling plt.savefig('x.png') still writes beside the notebook. The tree check above is what actually catches that, which is why it lives in course_main and applies to every course kind rather than to notebooks. `_have jupyter` was also the wrong probe. jupyter on this machine is a pipx shim whose interpreter is gone: `command -v` finds it, and running it exits 127. Both the probe and the runner now use `_works`, which runs `--version`. `./Programming/twm_4/run.sh --probe` reports "needs jupyter" and exit 78 instead of failing confusingly mid-run. Co-Authored-By: Claude Opus 5 --- scripts/course_lib.sh | 29 ++++++++++++++++++++++++++++- scripts/course_probe.sh | 2 +- scripts/course_run.sh | 15 ++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/course_lib.sh b/scripts/course_lib.sh index 2c26754d..9ea3f682 100644 --- a/scripts/course_lib.sh +++ b/scripts/course_lib.sh @@ -29,6 +29,12 @@ readonly COURSE_GATES _have() { command -v "$1" > /dev/null 2>&1; } +# `command -v` is true for a dead pipx shim: the file is there and executable, +# and only running it shows that the interpreter it points at is gone. That is +# what `jupyter` is on this machine, and it turned a clean "blocked: needs +# jupyter" into a confusing exec failure. Probe by running, not by looking. +_works() { _have "$1" && "$1" --version > /dev/null 2>&1; } + # Report "blocked" with the reason, which is the whole point of the status # table: "needs X" is useful, "failed" is not. _blocked() { @@ -43,6 +49,17 @@ _no_code() { _runnable() { printf '%s\n' "$1"; exit 0; } +# What the course tree looks like to git. Compared before and after a run, +# because executing coursework has twice silently overwritten a submitted +# deliverable -- TRAK's outputs/output.png and SPD's results_*.png -- and +# neither was noticed until a much later `git status`. Gitignored build output +# does not show up here, which is why every runner writes into build/. +# numstat is included so that re-writing an already-modified file still counts. +_tree_state() { + git -C "$COURSE_ROOT" status --porcelain -- "$COURSE_ROOT" 2>/dev/null || true + git -C "$COURSE_ROOT" diff --numstat -- "$COURSE_ROOT" 2>/dev/null || true +} + # First tool present wins; otherwise blocked naming all the candidates. _require_any() { local label="$1"; shift @@ -120,7 +137,17 @@ course_main() { printf 'usage: %s [--probe]\n' "$(basename "$0")" printf ' --probe report runnable/blocked/no-code without running\n' ;; - "") _run ;; + "") + local before after + before="$(_tree_state)" + _run + after="$(_tree_state)" + if [[ "$before" != "$after" ]]; then + printf 'FAILED: the run changed files under %s\n' "$COURSE_ROOT" >&2 + diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true + return 1 + fi + ;; *) printf 'unknown option: %s\n' "$1" >&2; return 1 ;; esac } diff --git a/scripts/course_probe.sh b/scripts/course_probe.sh index e399b149..5dc43347 100644 --- a/scripts/course_probe.sh +++ b/scripts/course_probe.sh @@ -52,7 +52,7 @@ _probe() { _runnable "compiles each standalone .cpp" ;; notebook) - _have jupyter || _blocked "needs jupyter" + _works jupyter || _blocked "needs jupyter" # Being on PATH is not the same as working: this machine has a # jupyter shim whose pipx venv interpreter is gone, so it fails # with "bad interpreter". Probe by actually invoking it. diff --git a/scripts/course_run.sh b/scripts/course_run.sh index ee6b39fd..93a96d19 100644 --- a/scripts/course_run.sh +++ b/scripts/course_run.sh @@ -181,11 +181,20 @@ _run() { printf 'binaries in %s/build\n' "$COURSE_ROOT" ;; notebook) - _have jupyter || _blocked "needs jupyter" - local nb + _works jupyter || _blocked "needs jupyter" + # NOT --inplace: all four notebooks in this repo are tracked WITH + # their outputs, so executing in place rewrites a deliverable. + # --output-dir sends the executed copy to a temp dir instead. + # It does NOT redirect what the notebook's own code writes -- the + # kernel's cwd is still the notebook's directory -- so the + # before/after tree check in course_main is what actually catches + # a notebook that saves a figure beside itself. + local nb nbtmp + nbtmp="$(mktemp -d)" while IFS= read -r nb; do - jupyter nbconvert --to notebook --execute --inplace "$nb" + jupyter nbconvert --to notebook --execute --output-dir "$nbtmp" "$nb" done < <(find "$COURSE_ROOT" -name '*.ipynb' -not -path '*/.ipynb_checkpoints/*' | sort) + rm -rf "$nbtmp" ;; matlab) local tool