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 +}