WUT_Computer_Science/scripts/course_lib.sh
Krzysztof kuhy Rudnicki 7b019d103f Gate the zero, and fix the LaTeX course the run guard exposed
The archive reached zero ruff findings this morning, so the standing rule in
.pre-commit-config.yaml -- a hook lands only once the thing it gates is clean
-- is finally satisfied for ruff. `ruff check` and `ruff format --check` are
hooks now, pinned at v0.16.5 per rules/dependency-freshness, plus
`no-suppressions`, which rejects `# noqa` and `# type: ignore`. CLAUDE.md has
always claimed a hook did that; until now the only thing stopping them was
whoever was reading ruff's output, and I typed `# noqa-free` three times in
this session by accident. mypy stays out: six of its errors are third-party
typing limits rather than our code.

The first attempt used `language: system`, which passed `pre-commit run
--all-files` and then failed the actual commit with "Executable `ruff` not
found" -- git hooks do not get ~/.local/bin on PATH. Taking the hooks from
astral-sh/ruff-pre-commit means pre-commit installs ruff itself, which is also
what makes the pin mean anything.

`ruff format --check` failed the moment it was wired up, on four TRAK files I
wrote today and never formatted. Formatted, and the ray_tracing0 render is
still 02484bdb6b6c1ddeff317d7e5566580f.

Then the run guard from f780d023 needed proving on more than TRAK, since it
now applies to every course kind. make, cpp and python courses pass it; node
and target and obj are all properly gitignored, so npm, maven and dotnet
builds cannot trip it either. But NotProgramming/MOM failed, and not because
of the guard:

`_first_file '*.tex'` used `find -print -quit`, which returns whatever readdir
hands back. After the 19 reports were split into sections, that was
`report_three/sections/02-model-dwukryterialny.tex`, and latexmk faithfully
tried to typeset a bare `\section` as a document. DOCS-runnability.md has
recorded MOM as runnable throughout. The tex branch now picks a file that
actually contains `\documentclass`.

Deliberately not sorted, and that is the interesting part. Sorting is the
obvious fix and it made things worse: it swapped ELAC's pick to projectA and
AIS's to report/final/ver1, both of which fail with latexmk exit 12. Those two
documents are broken independently, and sorting merely surfaced them; turning
two green courses red to fix a third is a bad trade. Recorded in
TODO-refactor-remaining.md instead. MOM, ELAC, AIS, SPD, ZPOB, SDM, DPZ and
ESOEN all exit 0 now.

`_works` also grew a `timeout 5`, because `unityhub --version` never returns
on this machine and a probe without one would hang the status sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:41:34 +02:00

179 lines
6.6 KiB
Bash

# shellcheck shell=bash
# Shared behaviour for every per-course run.sh. Sourced, never executed.
#
# A course run.sh is a stub: it declares what kind of project it is, then calls
# course_main. The logic lives here once, because 44 hand-copied runners is
# precisely how gates and scripts in this fleet have drifted apart before.
#
# Contract (see ../../run.sh):
# --probe report whether this could run HERE, without running it
# exit 0 runnable | 78 blocked | 79 nothing to run
# (no args) actually run it
#
# A stub calls:
# course_main --kind <kind> [--entry <path>] [--entry-args <str>]
# [--note <text>] [--test-dir <path>] [--no-bibtex] -- "$@"
# Values are passed as arguments rather than set as globals so that a stub has
# no assignments a linter has to be told to ignore.
readonly COURSE_EXIT_BLOCKED=78
readonly COURSE_EXIT_NO_CODE=79
COURSE_ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[1]}")")" && pwd)"
readonly COURSE_ROOT
# Repo root is two levels up: every course dir is <repo>/<area>/<course>.
COURSE_GATES="$(cd "${COURSE_ROOT}/../.." && pwd)/gates"
readonly COURSE_GATES
# --- helpers -----------------------------------------------------------------
_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.
#
# The timeout is not paranoia: `unityhub --version` on this machine never
# returns, so a probe without one would hang the whole status sweep. Use
# _works only for tools that answer --version; _have is right for the rest.
_works() { _have "$1" && timeout 5 "$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() {
printf '%s\n' "$1"
exit "$COURSE_EXIT_BLOCKED"
}
_no_code() {
printf '%s\n' "$1"
exit "$COURSE_EXIT_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
local tool
for tool in "$@"; do
if _have "$tool"; then
printf '%s' "$tool"
return 0
fi
done
_blocked "needs $label (none of: $*)"
}
# Probe-side variant. _require_any prints the tool it found, so probes have to
# redirect its stdout -- which also swallowed the failure REASON and turned
# every blocked probe into "unknown". This one returns a status and prints
# only on failure.
_probe_any() {
local label="$1"; shift
local tool
for tool in "$@"; do
_have "$tool" && return 0
done
_blocked "needs $label (none of: $*)"
}
# True if the path is recorded in gates/known-incomplete.txt.
_is_known_incomplete() {
local manifest="${COURSE_GATES:-}/known-incomplete.txt"
[[ -f "$manifest" ]] || return 1
local rel="$1"
grep -vE '^\s*#|^\s*$' "$manifest" | grep -qxF "$rel"
}
_first_file() {
find "$COURSE_ROOT" -name "$1" -not -path '*/node_modules/*' -print -quit 2>/dev/null
}
# The .tex that is an actual document, not one of the ~100 \input fragments
# the 2026-08 split created. `_first_file '*.tex'` returns whatever readdir
# hands back first, which for MOM was report_three/sections/02-*.tex; latexmk
# then faithfully tried to typeset a bare \section and failed with "Missing
# \begin{document}".
#
# Deliberately NOT sorted: readdir order is what every other tex course has
# been building all along, and sorting changed ELAC's pick from projectB to
# projectA and AIS's to report/final/ver1 -- both of which fail to build. The
# fragments are the bug; which document a multi-report course picks is a
# separate question, recorded in TODO-refactor-remaining.md.
_first_tex_document() {
local candidate
while IFS= read -r candidate; do
if grep -q '\\documentclass' "$candidate"; then
printf '%s' "$candidate"
return 0
fi
done < <(find "$COURSE_ROOT" -name '*.tex' -not -path '*/build/*')
}
# The probe and the runner are one case statement each and together are most of
# this file's length, so they live beside it rather than in it. SCRIPTDIR is
# resolved from BASH_SOURCE[0] -- BASH_SOURCE[1] is the *caller*, which is a
# per-course run.sh two directories away.
_COURSE_LIB_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=course_probe.sh
. "${_COURSE_LIB_DIR}/course_probe.sh"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=course_run.sh
. "${_COURSE_LIB_DIR}/course_run.sh"
course_main() {
COURSE_KIND=""
COURSE_ENTRY=""
COURSE_NOTE=""
COURSE_TEST_DIR=""
COURSE_TEX_NO_BIBTEX=""
COURSE_ENTRY_ARGS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--kind) COURSE_KIND="$2"; shift 2 ;;
--entry) COURSE_ENTRY="$2"; shift 2 ;;
--note) COURSE_NOTE="$2"; shift 2 ;;
--test-dir) COURSE_TEST_DIR="$2"; shift 2 ;;
--no-bibtex) COURSE_TEX_NO_BIBTEX=1; shift ;;
--entry-args) COURSE_ENTRY_ARGS="$2"; shift 2 ;;
--) shift; break ;;
*) break ;;
esac
done
case "${1:-}" in
--probe) _probe ;;
-h | --help)
printf 'usage: %s [--probe]\n' "$(basename "$0")"
printf ' --probe report runnable/blocked/no-code without running\n'
;;
"")
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
}