diff --git a/CLAUDE.md b/CLAUDE.md
index 9a6cac5f..fc118abb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,9 +33,22 @@ Work directly on `main`; commit and push straight there.
./install.sh # deps + pre-commit hooks, then verifies and fails loudly
./run.sh check # the full local gate
./run.sh status # probe every course dir, regenerate DOCS-runnability.md
-./run.sh run
# build/run one course, capped at 2G RAM and 2 cores
+./run.sh run # build AND run one course, capped at 2G RAM and 2 cores
+./run.sh run --list # what targets does this course have?
+./run.sh run 2 # run target 2 (an index, path substring, or "all")
+./run.sh run --batch # never prompt; stdin /dev/null, 60s per program
```
+`run` runs the program, it does not just build it. Twelve courses have more
+than one target (EPFU a game and a lab, EOOP six binaries, MOM three reports);
+with no target named, an interactive `run` prints a numbered menu and asks.
+Not a terminal, or `--batch`, means all targets and no prompt -- a prompt
+nobody can answer is how an unattended run hangs. `--probe`/`status` never
+execute anything and never enumerate targets, so the sweep stays seconds long.
+
+It is interactive by default on purpose: EPFU's penguins and ECOAR's puzzle
+both read stdin, and you typed `run`, so you are at the keyboard.
+
`run` executes inside a transient systemd scope (`MemoryMax=2G`,
`MemorySwapMax=0`, `CPUQuota=200%`, `nice -n 19`, `ionice -c 3`), so a course
build cannot make an interactive machine stutter. This is not caution, it is a
@@ -96,6 +109,16 @@ is mirrored into `~/utils/file_length/_tables.py`, `[tool.ruff] exclude` and
- `ruff` runs with `select = ["ALL"]` and mypy with `strict = true`.
- No `# noqa`, no `# type: ignore`. A pre-commit hook rejects both outright.
+- mypy runs **per directory**, never repo-wide: many courses ship a `main.py`
+ and mypy stops at `Duplicate module named "main"`. Point it at the course's
+ own venv or every import is unresolvable:
+ `mypy --strict --python-executable Programming//.venv/bin/python `.
+- **One deliberate mypy override exists**, in `pyproject.toml`:
+ `temperature_anomaly_detector` sets `disallow_subclassing_any = false`
+ because it must subclass pyflink's `MapFunction` and `KeyedProcessFunction`,
+ which ship no types and are not installed in `Programming/PSD/.venv`. It is
+ scoped to that one module. Do not widen it, and do not add a second override
+ for our own code without the same kind of reason written beside it.
- **No per-file-ignores either, including for tests.** `pyproject.toml` has no
`[tool.ruff.lint.per-file-ignores]` table at all, which is stricter than the
rest of the fleet. So a test asserts by raising:
diff --git a/TODO-refactor-remaining.md b/TODO-refactor-remaining.md
index 01017d04..27fac10d 100644
--- a/TODO-refactor-remaining.md
+++ b/TODO-refactor-remaining.md
@@ -39,7 +39,7 @@ own docs and the rest of this fleet carry. A test asserts by raising.
What that cost, and what it bought, is in the commit messages; the standing
rules for the next pass are below.
-## mypy: run per directory, six deliberate errors left
+## mypy: run per directory; the six deliberate errors are gone
`pyproject.toml` sets `strict = true` and `CLAUDE.md` says the repo runs mypy.
mypy IS installed now (`~/.local/bin/mypy`) -- the earlier note here saying it
@@ -55,7 +55,8 @@ RUFF findings unless it says otherwise**; only `TRAK` has been type-checked.
| `TRAK` | **0** -- cleared 2026-08-29 |
| `twm_4` | **0** -- cleared 2026-08-29 |
| `PBAD` | not run (no venv; notebooks only) |
-| `PSD` | **2**, both unfixable -- see below |
+| `PSD` | **0** -- cleared 2026-08-29 (scoped override; see below) |
+| `ERSMS-project` | **0** -- cleared 2026-08-29 (typed DeclarativeBase) |
Two structural notes for whoever runs it next:
@@ -68,16 +69,25 @@ Two structural notes for whoever runs it next:
`mypy --strict --python-executable Programming//.venv/bin/python `.
Without that, TRAK reported 4 phantom `matplotlib` import errors on top of
the real ones.
-- **`ERSMS-project/backend/app.py` keeps four errors on purpose.**
- flask-sqlalchemy builds `db.Model` dynamically, so mypy sees `Any` and
- `--strict` refuses to subclass it. Migrating to a typed `DeclarativeBase`
- would rewrite both models in an app that cannot be run here.
-- **PSD keeps two mypy errors on purpose.** `--strict` implies
- `disallow_subclassing_any`, and `temperature_anomaly_detector.py` has to
- subclass pyflink's `MapFunction` and `KeyedProcessFunction`, which ship no
- types. The only fixes are to weaken strict globally or to add an override
- for our own module; neither is worth it, so the two are reported rather
- than hidden. Everything else in PSD is clean under `--strict`.
+- **`ERSMS-project` is at 0** (was 4), fixed 2026-08-29. The old note here
+ claimed a fix "would rewrite both models"; that was wrong. flask-sqlalchemy
+ 3.1.1 *does* ship `py.typed`. The actual blocker was that `class
+ User(db.Model)` is attribute access, which mypy cannot resolve in a
+ base-class position. `backend/models.py` now declares a named
+ `DeclarativeBase` with SQLAlchemy 2.0 `Mapped[]` columns, and the six
+ legacy `Model.query` call sites became `db.session` selects, because the
+ `Model` mixin goes away with the base. Verified by driving the real routes
+ through Flask's test client with Firebase stubbed: 16 scenarios and both
+ `CREATE TABLE` statements byte-identical to the submitted version.
+ Two traps worth keeping: `mapped_column(ForeignKey("user.uid"))` silently
+ infers VARCHAR from the referenced column, which changed the submitted
+ `user_id INTEGER` until an explicit `Integer` was put back; and
+ `backend/Dockerfile` copied only `app.py`, so the split needed it too.
+- **PSD is at 0** (was 2), by a scoped `[[tool.mypy.overrides]]` for
+ `temperature_anomaly_detector` with `disallow_subclassing_any = false`.
+ pyflink ships no types AND is not installed in that venv, so a hand-written
+ `.pyi` would assert an API nothing here can check. Recorded in `CLAUDE.md`
+ as well as here, because this file gets deleted and that one does not.
- Point `MYPYPATH` at `Programming/PSD/zin3/python/code` for that tree: the
modules import `model.*` via a runtime `sys.path.append`, which mypy
cannot follow.
@@ -99,8 +109,11 @@ exit 0.
Two things surfaced on the way and are still true:
-- **A multi-report course builds one arbitrary report.** MOM has three, ELAC
- two, AIS several drafts, and which one gets built depends on readdir order.
+- **A multi-report course builds one arbitrary report.** PARTLY FIXED
+ 2026-08-29: `run.sh run ` now enumerates every document and either asks
+ which, or builds all of them when not interactive, so nothing depends on
+ readdir order any more. What is still open is that some of those documents
+ are broken -- see below. MOM has three, ELAC two, AIS several drafts.
Sorting looked like the fix until it swapped ELAC to `projectA` and AIS to
`report/final/ver1`, **both of which fail with latexmk exit 12** -- so those
documents are broken and sorting merely surfaced it. Either build every
@@ -110,6 +123,26 @@ Two things surfaced on the way and are still true:
carries a `timeout 5`. Use `_works` only for tools that answer `--version`;
`_have` is still right for the other ten probe call sites.
+## Found by actually running the programs, not yet fixed
+
+`run.sh run` executes what it builds as of 2026-08-29, and the first thing that
+fell out is a course that had never been run by the harness at all:
+
+- **`EPFU/labs/krudnic3_lab1.c` dies with SIGFPE** (exit 136) on
+ `printf("\n%f", i_one / i_two)` -- an integer division by zero. It was
+ invisible before because EPFU's stub pins `--entry` to `penguins/src`, so
+ the lab was never compiled, let alone run. gcc also warns twice about
+ `%d`/`%f` against the wrong argument types on lines 12 and 14. Left alone
+ for now: it is a submitted deliverable and fixing it is a separate decision
+ from making the runner work.
+- **`ECOAR` overwrites a tracked deliverable when it runs.** `C/puzzle.out`
+ writes `C/dest.bmp` beside its source, so the before/after tree guard fails
+ the run -- correctly. It was invisible before for the same reason as EPFU's
+ lab: the runner built `puzzle.out` and never executed it. The fix is to make
+ the program write into `build/`, which means touching a submitted `.c`, so
+ it is recorded rather than done. `git checkout --` restores the file
+ meanwhile; the guard is what makes that recoverable at all.
+
## Still open
### 1. The 250-line cap: 50 files
diff --git a/run.sh b/run.sh
index 47d6c0c6..5a114412 100755
--- a/run.sh
+++ b/run.sh
@@ -7,12 +7,23 @@
# ./run.sh install alias for ./install.sh
# ./run.sh status probe every course dir, rewrite DOCS-runnability.md
# ./run.sh run actually execute one course project
+# ./run.sh run --list list that course's targets, run nothing
+# ./run.sh run run one target: an index, a path substring,
+# or "all". Without one, a course with several
+# targets asks which; a course with one just runs.
+# ./run.sh run --batch never prompt, stdin is /dev/null, timeout each
+# run. This is the unattended/CI form.
# ./run.sh sync-vendored show the mirrors that gates/vendored.txt feeds
#
# `status` PROBES: it asks each directory whether its toolchain is present and
# never executes the project, so it takes seconds and is safe in CI. Use
# `run ` to actually run something. Those are very different commands and
# conflating them is how a "status check" turns into 44 builds.
+#
+# `run` BUILDS AND THEN RUNS. Building is not running: this used to print one
+# gcc line and stop, leaving you to find the binary yourself. It is interactive
+# by default because several of these programs read stdin -- use --batch for
+# anything unattended.
# ============================================================================
set -euo pipefail
@@ -150,6 +161,7 @@ MD
cmd_run() {
local target="${1:-}"
[[ -n "$target" ]] || { err "run needs a directory"; usage 1; }
+ shift
local runner="${SCRIPT_DIR}/${target}/run.sh"
[[ -x "$runner" ]] || { err "no executable run.sh in $target"; return 1; }
log "running $target (capped at ${RUN_MEMORY_MAX} RAM, ${RUN_CPU_QUOTA} CPU)"
@@ -160,7 +172,10 @@ cmd_run() {
# export, not an assignment prefix: _run_capped is a shell function, and a
# prefixed assignment on one is not put into the child's environment.
export MPLBACKEND="${MPLBACKEND:-Agg}"
- _run_capped "$runner"
+ # Remaining arguments are the course runner's: a target to pick, --batch,
+ # --list. They are forwarded rather than parsed here, because which
+ # targets exist is a question only the course runner can answer.
+ _run_capped "$runner" "$@"
}
# Run a command under a memory/CPU cap, niced so it yields to anything
diff --git a/scripts/course_exec.sh b/scripts/course_exec.sh
new file mode 100644
index 00000000..151c3e48
--- /dev/null
+++ b/scripts/course_exec.sh
@@ -0,0 +1,120 @@
+# shellcheck shell=bash
+# Running what the build produced. Sourced by course_lib.sh, never run.
+#
+# Building is not running. `./run.sh run EPFU` used to print one `gcc` line and
+# stop, leaving you to find `a.out` yourself -- reported by kuhy on 2026-08-29:
+# "I run run.sh and I have no idea where the actual program is and how to run
+# it". Every kind that produces an executable now executes it and says so.
+#
+# Interactive by default: you typed `run`, so you are at the keyboard, and
+# EPFU's penguins and ECOAR's C both read stdin. Under --batch stdin is
+# /dev/null and a timeout applies, so an unattended run cannot wedge. --probe
+# never reaches this file at all.
+
+COURSE_EXEC_FAILURES=0
+readonly COURSE_TIMEOUT_EXIT=124
+
+_batch_timeout() { printf '%s' "${COURSE_BATCH_TIMEOUT:-60}"; }
+
+_exec_note() {
+ local rc="$1" what="$2"
+ if [[ "$rc" -eq 0 ]]; then
+ return 0
+ fi
+ COURSE_EXEC_FAILURES=$((COURSE_EXEC_FAILURES + 1))
+ if [[ "$rc" -eq "$COURSE_TIMEOUT_EXIT" && -n "${COURSE_BATCH:-}" ]]; then
+ # Not a failure of the program: --batch gives stdin as /dev/null, and
+ # an interactive one is supposed to sit there. Say which it was.
+ printf ' %s: still running after %ss, stopped (batch mode)\n' \
+ "$what" "$(_batch_timeout)" >&2
+ else
+ printf ' %s: exited %s\n' "$what" "$rc" >&2
+ fi
+}
+
+# Run one built artifact, from its own directory so relative data files resolve.
+_exec_built() {
+ local exe="$1" label rc=0
+ label="$(_target_label "$exe")"
+ printf '\n--> running %s\n' "$label"
+ if [[ -n "${COURSE_BATCH:-}" ]]; then
+ ( cd "$(dirname "$exe")" \
+ && timeout "$(_batch_timeout)" "$exe" < /dev/null ) || rc=$?
+ else
+ ( cd "$(dirname "$exe")" && "$exe" ) || rc=$?
+ fi
+ _exec_note "$rc" "$label"
+}
+
+# Every executable file under a directory, with its mtime, so that "what did
+# the build just produce" can be answered by comparing two snapshots. A
+# makefile need not say what it writes, and EPFU's does not: `gcc ... main.c`
+# with no -o leaves an a.out that nothing mentions.
+_executable_snapshot() {
+ find "$1" -type f -executable \
+ -not -name '*.sh' -not -name '*.py' -not -name '*.pl' \
+ -not -path '*/.git/*' -not -path '*/.venv/*' \
+ -printf '%p\t%T@\n' 2>/dev/null | sort
+}
+
+# Run whatever appeared or changed between two snapshots.
+_exec_new_binaries() {
+ local before="$1" after="$2" what="$3"
+ local new_paths=()
+ local line path
+ while IFS= read -r line; do
+ path="${line%%$'\t'*}"
+ new_paths+=("$path")
+ done < <(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after"))
+
+ if [[ ${#new_paths[@]} -eq 0 ]]; then
+ printf 'nothing new to run after %s (already built, or it produces no binary)\n' \
+ "$what"
+ return 0
+ fi
+ local exe
+ for exe in "${new_paths[@]}"; do
+ _exec_built "$exe"
+ done
+}
+
+# `make` in one directory, then run what it produced.
+_make_and_run() {
+ local dir="$1" before after
+ before="$(_executable_snapshot "$dir")"
+ ( cd "$dir" && make )
+ after="$(_executable_snapshot "$dir")"
+ _exec_new_binaries "$before" "$after" "make in $(_target_label "$dir")"
+}
+
+# A jar is not self-describing either: pick the one maven just wrote, skipping
+# the sources/javadoc siblings that are not runnable.
+_run_jar() {
+ local dir="$1" jar
+ jar="$(find "$dir" -path '*/target/*.jar' \
+ -not -name '*-sources.jar' -not -name '*-javadoc.jar' -print -quit 2>/dev/null)"
+ if [[ -z "$jar" ]]; then
+ printf 'built, but no runnable jar under %s/target\n' "$(_target_label "$dir")"
+ return 0
+ fi
+ local rc=0
+ printf '\n--> running %s\n' "$(_target_label "$jar")"
+ if [[ -n "${COURSE_BATCH:-}" ]]; then
+ ( cd "$dir" && timeout "$(_batch_timeout)" java -jar "$jar" < /dev/null ) || rc=$?
+ else
+ ( cd "$dir" && java -jar "$jar" ) || rc=$?
+ fi
+ _exec_note "$rc" "$(_target_label "$jar")"
+}
+
+# A loose C program with no makefile of its own. Same deal as the cxx kind:
+# compile into the gitignored build/, then actually run it.
+_compile_and_run_c() {
+ local src="$1" cc out
+ cc="$(_require_any 'a C compiler' gcc cc clang)"
+ mkdir -p "$COURSE_ROOT/build"
+ out="$COURSE_ROOT/build/$(basename "${src%.c}")"
+ printf 'compiling %s\n' "$(_target_label "$src")"
+ "$cc" -O2 -Wall -Wextra -o "$out" "$src"
+ _exec_built "$out"
+}
diff --git a/scripts/course_lib.sh b/scripts/course_lib.sh
index c995c433..a74e6e4b 100644
--- a/scripts/course_lib.sh
+++ b/scripts/course_lib.sh
@@ -144,6 +144,12 @@ _COURSE_LIB_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=course_run.sh
. "${_COURSE_LIB_DIR}/course_run.sh"
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=course_targets.sh
+. "${_COURSE_LIB_DIR}/course_targets.sh"
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=course_exec.sh
+. "${_COURSE_LIB_DIR}/course_exec.sh"
course_main() {
COURSE_KIND=""
@@ -165,23 +171,59 @@ course_main() {
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'
+ # Everything after `--` is the user's, not the stub's.
+ COURSE_BATCH=""
+ COURSE_SELECT=""
+ local mode="run"
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --probe) mode="probe"; shift ;;
+ --list) mode="list"; shift ;;
+ --batch) COURSE_BATCH=1; shift ;;
+ -h | --help) mode="help"; shift ;;
+ -*) printf 'unknown option: %s\n' "$1" >&2; return 1 ;;
+ # A bare word picks a target: an index, a path substring, or "all".
+ *) COURSE_SELECT="$1"; shift ;;
+ esac
+ done
+
+ case "$mode" in
+ # --probe must stay cheap and must never execute anything, so it does
+ # not even enumerate targets.
+ probe) _probe ;;
+ help)
+ printf 'usage: %s [--probe|--list] [--batch] []\n' "$(basename "$0")"
+ printf ' --probe report runnable/blocked/no-code without running\n'
+ printf ' --list list this course'"'"'s targets and stop\n'
+ printf ' --batch no prompts, stdin is /dev/null, %ss timeout per run\n' \
+ "$(_batch_timeout)"
+ printf ' an index, a path substring, or "all"\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 ;;
+ list) _collect_targets; _print_menu ;;
+ run) _run_selected ;;
esac
}
+
+# Run every chosen target, with the tree guard around the whole thing.
+_run_selected() {
+ _collect_targets
+ _choose_target || return 1
+
+ local before after target
+ before="$(_tree_state)"
+ for target in "${COURSE_CHOSEN[@]}"; do
+ COURSE_ENTRY="$target"
+ _run
+ done
+ 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
+ if [[ "$COURSE_EXEC_FAILURES" -gt 0 ]]; then
+ printf '\n%s of the programs did not exit cleanly (see above)\n' \
+ "$COURSE_EXEC_FAILURES" >&2
+ return 1
+ fi
+}
diff --git a/scripts/course_run.sh b/scripts/course_run.sh
index 0c5042fc..09970020 100644
--- a/scripts/course_run.sh
+++ b/scripts/course_run.sh
@@ -146,39 +146,66 @@ _run() {
_python_run_tests
;;
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 ) ;;
+ maven)
+ ( cd "${COURSE_ENTRY:-$COURSE_ROOT}" && mvn -q package )
+ _run_jar "${COURSE_ENTRY:-$COURSE_ROOT}"
+ ;;
+ make)
+ # A target is either a directory with a makefile, or a standalone
+ # .c program that no makefile covers.
+ local mtarget="${COURSE_ENTRY:-$COURSE_ROOT}"
+ if [[ -f "$mtarget" ]]; then
+ _compile_and_run_c "$mtarget"
+ else
+ _make_and_run "$mtarget"
+ fi
+ ;;
+ dotnet)
+ # `dotnet run` builds too, so this is one step, not two.
+ # EGUI is a web app: running it starts Kestrel and stays
+ # up, which is what running it means. --batch caps that.
+ local dir rc=0
+ dir="${COURSE_ENTRY:-$COURSE_ROOT}"
+ if [[ -n "${COURSE_BATCH:-}" ]]; then
+ ( cd "$dir" && timeout "$(_batch_timeout)" \
+ dotnet run < /dev/null ) || rc=$?
+ else
+ ( cd "$dir" && dotnet run ) || rc=$?
+ fi
+ _exec_note "$rc" "$(_target_label "$dir")"
+ ;;
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.
+ # One .cpp per invocation: each is a self-contained assignment with
+ # its own main(), and the target list already enumerated them, so
+ # picking one is the caller's job rather than this loop's.
local cxx src rel out
cxx="$(_require_any 'a C++ compiler' g++ clang++)"
+ src="${COURSE_ENTRY:-}"
+ [[ -f "$src" ]] || _no_code "no .cpp entry to compile"
+ rel="${src#"$COURSE_ROOT"/}"
# 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"/}"
- if _is_known_incomplete "${src#"${COURSE_GATES%/gates}"/}"; then
- printf 'skipping %s (see gates/known-incomplete.txt)\n' "$rel"
- continue
- fi
- 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"
+ if _is_known_incomplete "${src#"${COURSE_GATES%/gates}"/}"; then
+ printf 'skipping %s (see gates/known-incomplete.txt)\n' "$rel"
+ return 0
+ fi
+ 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, but there is then
+ # nothing to run.
+ if grep -qE '\bint[[:space:]]+main[[:space:]]*\(' "$src"; then
+ printf 'compiling %s\n' "$rel"
+ "$cxx" -O2 -o "$out" "$src"
+ _exec_built "$out"
+ else
+ printf 'checking %s (no main; -c only)\n' "$rel"
+ "$cxx" -O2 -c -o "${out}.o" "$src"
+ fi
;;
notebook)
_require_working jupyter
diff --git a/scripts/course_targets.sh b/scripts/course_targets.sh
new file mode 100644
index 00000000..cb02503f
--- /dev/null
+++ b/scripts/course_targets.sh
@@ -0,0 +1,157 @@
+# shellcheck shell=bash
+# Enumerating and choosing what to run. Sourced by course_lib.sh, never run.
+#
+# A course directory is not always one program. EPFU ships a game AND a lab
+# exercise, EOOP six standalone .cpp files, MOM three reports -- twelve courses
+# have more than one target. The runner used to build whichever one `find`
+# handed back first and say nothing about it, which is how you end up staring
+# at a single gcc line with no idea what it just did.
+#
+# Enumeration scans COURSE_ROOT rather than only COURSE_ENTRY on purpose: EPFU
+# pins --entry to penguins/src, and that pin is exactly why its labs/ was
+# invisible. --entry still decides the DEFAULT, it no longer decides the only.
+
+COURSE_TARGETS=()
+COURSE_CHOSEN=()
+
+# Fill COURSE_TARGETS for this kind. One entry means there is no choice to make.
+_collect_targets() {
+ COURSE_TARGETS=()
+ local f
+ case "$COURSE_KIND" in
+ tex)
+ while IFS= read -r f; do
+ if grep -q '\\documentclass' "$f"; then COURSE_TARGETS+=("$f"); fi
+ done < <(find "$COURSE_ROOT" -name '*.tex' -not -path '*/build/*' | sort)
+ ;;
+ python)
+ while IFS= read -r f; do
+ COURSE_TARGETS+=("$f")
+ done < <(find "$COURSE_ROOT" -name 'main.py' -not -path '*/.venv/*' | sort)
+ ;;
+ make)
+ local mkdirs=()
+ while IFS= read -r f; do
+ mkdirs+=("$(dirname "$f")")
+ COURSE_TARGETS+=("$(dirname "$f")")
+ done < <(find "$COURSE_ROOT" \( -name Makefile -o -name makefile \) \
+ -not -path '*/build/*' -not -path '*/node_modules/*' | sort)
+ # A loose .c with its own main() and no makefile above it is still
+ # a program. EPFU's labs/krudnic3_lab1.c is the case that started
+ # this: --entry pinned the runner to penguins/, so the lab was
+ # invisible and nothing ever compiled it.
+ while IFS= read -r f; do
+ if grep -qE '\bint[[:space:]]+main[[:space:]]*\(' "$f" \
+ && ! _under_any "$f" ${mkdirs[@]+"${mkdirs[@]}"}; then
+ COURSE_TARGETS+=("$f")
+ fi
+ done < <(find "$COURSE_ROOT" -name '*.c' -not -path '*/build/*' \
+ -not -path '*/node_modules/*' | sort)
+ ;;
+ cxx)
+ while IFS= read -r f; do
+ if grep -qE '\bint[[:space:]]+main[[:space:]]*\(' "$f"; then
+ COURSE_TARGETS+=("$f")
+ fi
+ done < <(find "$COURSE_ROOT" -name '*.cpp' -not -path '*/build/*' \
+ -not -path '*/node_modules/*' | sort)
+ ;;
+ esac
+ # Kinds with no natural list -- and any course whose scan found nothing --
+ # keep the single implicit target the stub already declares.
+ if [[ ${#COURSE_TARGETS[@]} -eq 0 ]]; then
+ COURSE_TARGETS=("${COURSE_ENTRY:-$COURSE_ROOT}")
+ fi
+}
+
+_target_label() { printf '%s' "${1#"$COURSE_ROOT"/}"; }
+
+# Is this path inside any of the given directories? Used to keep a source file
+# that some makefile already builds out of the target list.
+_under_any() {
+ local path="$1"; shift
+ local dir
+ for dir in "$@"; do
+ if [[ "$path" == "$dir"/* ]]; then return 0; fi
+ done
+ return 1
+}
+
+# Resolve an explicit selection: an index, or a substring of a target's path.
+# Prints the chosen indices, one per line; empty output means "no match".
+_match_selection() {
+ local want="$1" i=1 t
+ if [[ "$want" == "all" ]]; then
+ for ((i = 1; i <= ${#COURSE_TARGETS[@]}; i++)); do printf '%s\n' "$i"; done
+ return 0
+ fi
+ if [[ "$want" =~ ^[0-9]+$ ]]; then
+ if [[ "$want" -ge 1 && "$want" -le ${#COURSE_TARGETS[@]} ]]; then
+ printf '%s\n' "$want"
+ fi
+ return 0
+ fi
+ for t in "${COURSE_TARGETS[@]}"; do
+ if [[ "$(_target_label "$t")" == *"$want"* ]]; then printf '%s\n' "$i"; fi
+ i=$((i + 1))
+ done
+}
+
+_print_menu() {
+ printf '\n%s has %d targets:\n\n' "${COURSE_ROOT##*/}" "${#COURSE_TARGETS[@]}"
+ local i=1 t marker
+ for t in "${COURSE_TARGETS[@]}"; do
+ marker=""
+ if [[ -n "${COURSE_ENTRY:-}" && "$t" == "$COURSE_ENTRY"* ]]; then
+ marker=" <- the stub's default"
+ fi
+ printf ' %2d) %s%s\n' "$i" "$(_target_label "$t")" "$marker"
+ i=$((i + 1))
+ done
+ printf ' a) all of them\n\n'
+}
+
+# Decide what to run, into COURSE_CHOSEN.
+_choose_target() {
+ COURSE_CHOSEN=()
+ local idx
+
+ if [[ ${#COURSE_TARGETS[@]} -eq 1 ]]; then
+ COURSE_CHOSEN=("${COURSE_TARGETS[@]}")
+ return 0
+ fi
+
+ # An explicit choice on the command line skips the prompt entirely, which
+ # is what makes the menu scriptable as well as answerable.
+ if [[ -n "${COURSE_SELECT:-}" ]]; then
+ while IFS= read -r idx; do
+ COURSE_CHOSEN+=("${COURSE_TARGETS[$((idx - 1))]}")
+ done < <(_match_selection "$COURSE_SELECT")
+ if [[ ${#COURSE_CHOSEN[@]} -eq 0 ]]; then
+ printf 'no target matches "%s"\n' "$COURSE_SELECT" >&2
+ _print_menu >&2
+ return 1
+ fi
+ return 0
+ fi
+
+ # A prompt nobody can answer is how an unattended run hangs forever, so
+ # --batch and a non-terminal stdin both mean "all of them" instead.
+ if [[ -n "${COURSE_BATCH:-}" || ! -t 0 ]]; then
+ COURSE_CHOSEN=("${COURSE_TARGETS[@]}")
+ printf 'not interactive: running all %d targets\n' "${#COURSE_TARGETS[@]}"
+ return 0
+ fi
+
+ _print_menu
+ local reply
+ read -r -p "which? [1-${#COURSE_TARGETS[@]} or a, default 1] " reply || reply=""
+ reply="${reply:-1}"
+ while IFS= read -r idx; do
+ COURSE_CHOSEN+=("${COURSE_TARGETS[$((idx - 1))]}")
+ done < <(_match_selection "$reply")
+ if [[ ${#COURSE_CHOSEN[@]} -eq 0 ]]; then
+ printf 'not a valid choice: %s\n' "$reply" >&2
+ return 1
+ fi
+}