run.sh runs the program now, and says which one it is running
Some checks are pending
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
runnability / runnability (push) Waiting to run

Reported by kuhy: "I run run.sh and I have no idea where the actual program is
and how to run it". In EPFU that was one `gcc -Wall -Wextra -pedantic main.c`
line and nothing else -- the makefile has no -o, so the binary is an a.out that
nothing mentions, and it was never executed.

Two separate failures, both fixed.

Building is not running. make, cxx, maven and dotnet built and stopped; make,
maven and dotnet did not even print where the output went, while cxx and tex
did. They all execute what they produced now. `make` is the awkward one,
because a makefile need not say what it writes: the runner snapshots the
executables either side of the build and runs whatever is new.

A course is not always one program. Twelve of them are not -- EPFU a game and
a lab, EOOP six binaries, EARIN eight labs, MOM three reports. With no target
named, an interactive run prints a numbered menu and asks; `run <dir> 2`, a
path substring, or `all` skips the prompt. Enumeration deliberately scans the
course root rather than --entry, because EPFU pinning --entry to penguins/src
is precisely why labs/krudnic3_lab1.c had never been compiled. --entry is the
default now, not the only.

Interactive by default: EPFU's penguins and ECOAR's puzzle both read stdin,
and you typed `run`, so you are at the keyboard. --batch (and any
non-terminal stdin) means no prompt, stdin from /dev/null and a 60s cap per
program, so an unattended run cannot wedge. --probe never reaches any of this
and does not even enumerate targets: all 44 probes produce a byte-identical
gates/runnability-local.tsv, and `status` is still seconds long.

Running things immediately found two bugs that could not have surfaced before,
both recorded in TODO-refactor-remaining.md rather than fixed here, since both
mean touching a submitted .c:

  - EPFU/labs/krudnic3_lab1.c dies with SIGFPE, integer division by zero.
  - ECOAR's puzzle.out writes dest.bmp beside its source, so the tree guard
    fails the run. That guard is doing exactly its job.

Verified: EPFU both targets, EOOP under a pty answering the prompt, EADS,
ECOAR, MOM. Full gate green, shellcheck clean on all 44 runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 11:00:46 +02:00
parent 2a59de6363
commit d5b94cd8a7
7 changed files with 474 additions and 57 deletions

View File

@ -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 <dir> # build/run one course, capped at 2G RAM and 2 cores
./run.sh run <dir> # build AND run one course, capped at 2G RAM and 2 cores
./run.sh run <dir> --list # what targets does this course have?
./run.sh run <dir> 2 # run target 2 (an index, path substring, or "all")
./run.sh run <dir> --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/<course>/.venv/bin/python <dir>`.
- **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:

View File

@ -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/<course>/.venv/bin/python <dir>`.
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 <dir>` 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

17
run.sh
View File

@ -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 <dir> actually execute one course project
# ./run.sh run <dir> --list list that course's targets, run nothing
# ./run.sh run <dir> <target> 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 <dir> --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 <dir>` 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

120
scripts/course_exec.sh Normal file
View File

@ -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"
}

View File

@ -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] [<target>]\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 ' <target> 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
}

View File

@ -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

157
scripts/course_targets.sh Normal file
View File

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