WUT_Computer_Science/scripts/course_lib.sh
Krzysztof kuhy Rudnicki e512bde26f Gate the invariants, fix ECOTE's unrunnable tests, contain build output
Two invariants had been established by hand and left ungated, which is the
"a remedy that only works if someone remembers" pattern:

  - scripts/check_ignore_consistency.sh fails the commit if any TRACKED file
    is also gitignored. .binary-allowlist used to *instruct* the reader to
    re-run that check; now it is enforced. Verified both ways -- passes on the
    tree, and fails when an ignored file is force-added.

DOCS-runnability.md now really has two columns. Each environment writes only
its own gates/runnability-<env>.tsv and the doc renders from both, so a CI run
cannot clobber the local results. The ci tsv is gitignored: committing a copy
generated here would label this machine's capabilities as the runner's.
Rendering it surfaced a classic bug -- awk's NR==FNR idiom silently swallows
the SECOND file when the first is empty, so the table came out with a header
and no rows. It compares FILENAME instead.

runnability.yml deliberately does NOT diff against a committed baseline. A
status is a property of the machine, so `runnable` locally and `blocked` on a
runner is correct rather than drift, and diffing would make the workflow red
by construction. It fails on `error` instead -- a runner that crashes or
reports an unknown kind.

Then five more courses were actually run, because EADS alone had produced four
real bugs and one sample proves nothing:

  - ECOAR and EPFU build (nasm + 32-bit gcc, and gcc respectively).
  - ECOTE's 17 test modules could not run AT ALL. Its package is named `code/`,
    which shadows the standard library: pytest's debugging plugin imports pdb,
    pdb imports code, and it got this directory instead, so the suite died
    with an INTERNALERROR before collecting a single test. Renamed to
    translator/ with all 17 imports updated -- 26 tests now pass.
  - A LaTeX report builds. That one exposed a hazard rather than a bug:
    latexmk wrote its PDF beside the source, and in a course where the built
    name matches the submitted one it would have silently overwritten a
    tracked deliverable. Both the LaTeX and C++ runners now build into a
    gitignored build/, and rebuilding ECOGR leaves `git status` empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 18:43:13 +02:00

234 lines
8.9 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>] [--note <text>] -- "$@"
# 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
# --- helpers -----------------------------------------------------------------
_have() { command -v "$1" > /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; }
# 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: $*)"
}
_first_file() {
find "$COURSE_ROOT" -name "$1" -not -path '*/node_modules/*' -print -quit 2>/dev/null
}
# --- probes ------------------------------------------------------------------
_probe() {
case "$COURSE_KIND" in
none) _no_code "${COURSE_NOTE:-no executable code in this directory}" ;;
tex)
_probe_any "a LaTeX toolchain" latexmk pdflatex xelatex
_runnable "builds ${COURSE_ENTRY:-the report} with latexmk"
;;
python)
_have python3 || _blocked "needs python3"
[[ -n "$(_first_file '*.py')" ]] || _no_code "no .py files found"
_runnable "python3 ${COURSE_ENTRY:-the entry script}"
;;
node)
_have node || _blocked "needs node"
_have npm || _blocked "needs npm"
_runnable "npm install && npm start"
;;
maven)
_have mvn || _blocked "needs maven"
_have java || _blocked "needs a JDK"
_runnable "mvn package"
;;
make)
_have make || _blocked "needs make"
_probe_any "a C compiler" gcc cc clang
_runnable "make"
;;
dotnet)
_have dotnet || _blocked "needs the .NET SDK"
_runnable "dotnet build"
;;
unity)
_have unity-editor || _have unityhub \
|| _blocked "needs the Unity editor (GUI, not installable on a CI runner)"
_runnable "opens in the Unity editor"
;;
matlab)
_probe_any "MATLAB or Octave" matlab octave
_runnable "runs the .m scripts"
;;
cxx)
_probe_any "a C++ compiler" g++ clang++
[[ -n "$(_first_file '*.cpp')" ]] || _no_code "no .cpp files found"
_runnable "compiles each standalone .cpp"
;;
notebook)
_have jupyter || _blocked "needs jupyter"
_runnable "jupyter nbconvert --execute"
;;
compose)
_have docker || _blocked "needs docker"
docker info > /dev/null 2>&1 || _blocked "docker is installed but the daemon is not running"
_runnable "docker compose up"
;;
*) _blocked "unknown COURSE_KIND '${COURSE_KIND:-unset}'" ;;
esac
}
# --- runners -----------------------------------------------------------------
_run() {
case "$COURSE_KIND" in
none) _no_code "${COURSE_NOTE:-nothing to run}" ;;
tex)
local tex="${COURSE_ENTRY:-}"
[[ -n "$tex" ]] || tex="$(_first_file '*.tex')"
[[ -n "$tex" ]] || _no_code "no .tex found"
local tool
tool="$(_require_any 'a LaTeX toolchain' latexmk pdflatex xelatex)"
# Build into build/, never beside the source. A submitted report.pdf
# is a tracked deliverable, and a rebuild whose output name happens
# to match it would silently overwrite what was handed in.
local dir base
dir="$(dirname "$tex")"
base="$(basename "$tex")"
if [[ $tool == latexmk ]]; then
( cd "$dir" && latexmk -pdf -interaction=nonstopmode -outdir=build "$base" )
else
( cd "$dir" && mkdir -p build \
&& "$tool" -interaction=nonstopmode -output-directory=build "$base" )
fi
printf 'output in %s/build\n' "$dir"
;;
python)
local entry="${COURSE_ENTRY:-}"
[[ -n "$entry" ]] || entry="$(_first_file 'main.py')"
[[ -n "$entry" ]] || _no_code "no entry point; see this directory's README"
( cd "$(dirname "$entry")" && python3 "$(basename "$entry")" )
;;
node) ( cd "${COURSE_ENTRY:-$COURSE_ROOT}" && npm install && npm start ) ;;
maven) ( cd "${COURSE_ENTRY:-$COURSE_ROOT}" && mvn -q package ) ;;
make) ( cd "${COURSE_ENTRY:-$COURSE_ROOT}" && make ) ;;
dotnet) ( cd "${COURSE_ENTRY:-$COURSE_ROOT}" && dotnet build ) ;;
compose) ( cd "${COURSE_ENTRY:-$COURSE_ROOT}" && docker compose up ) ;;
unity) _blocked "open ${COURSE_ROOT} in the Unity editor; there is no headless path" ;;
cxx)
# Each .cpp here is a self-contained assignment with its own main(),
# so they are compiled individually rather than linked together.
local cxx src rel out
cxx="$(_require_any 'a C++ compiler' g++ clang++)"
# Binaries go to build/, which is gitignored -- compiling in place
# drops untracked executables next to the sources, and an
# extensionless binary is not something .gitignore catches by name.
mkdir -p "$COURSE_ROOT/build"
while IFS= read -r src; do
rel="${src#"$COURSE_ROOT"/}"
out="$COURSE_ROOT/build/$(basename "${rel%.cpp}")"
# Some files here are exam-answer fragments with no main(), so
# they cannot be linked. Compile those to an object instead --
# that still type-checks them, which is the point.
if grep -qE '\bint[[:space:]]+main[[:space:]]*\(' "$src"; then
printf 'compiling %s\n' "$rel"
"$cxx" -O2 -o "$out" "$src"
else
printf 'checking %s (no main; -c only)\n' "$rel"
"$cxx" -O2 -c -o "${out}.o" "$src"
fi
done < <(find "$COURSE_ROOT" -name '*.cpp' -not -path '*/node_modules/*' -not -path '*/build/*' | sort)
printf 'binaries in %s/build\n' "$COURSE_ROOT"
;;
notebook)
_have jupyter || _blocked "needs jupyter"
local nb
while IFS= read -r nb; do
jupyter nbconvert --to notebook --execute --inplace "$nb"
done < <(find "$COURSE_ROOT" -name '*.ipynb' -not -path '*/.ipynb_checkpoints/*' | sort)
;;
matlab)
local tool
tool="$(_require_any 'MATLAB or Octave' matlab octave)"
printf 'run the .m files in %s with %s\n' "$COURSE_ROOT" "$tool"
;;
*) printf 'unknown COURSE_KIND: %s\n' "${COURSE_KIND:-unset}" >&2; return 1 ;;
esac
}
course_main() {
COURSE_KIND=""
COURSE_ENTRY=""
COURSE_NOTE=""
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 ;;
--) 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'
;;
"") _run ;;
*) printf 'unknown option: %s\n' "$1" >&2; return 1 ;;
esac
}