diff --git a/.github/workflows/repo-contract.yml b/.github/workflows/repo-contract.yml
new file mode 100644
index 00000000..89d55979
--- /dev/null
+++ b/.github/workflows/repo-contract.yml
@@ -0,0 +1,25 @@
+name: repo-contract
+
+# The local pre-commit hook can be skipped (a fresh clone has no hooks), so the
+# contract is enforced on push too. Mirrors md-naming.yml.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ repo-contract:
+ name: repo contract
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check out the shared gate
+ uses: actions/checkout@v4
+ with:
+ repository: kuhyx/utils
+ path: .utils
+
+ - name: Enforce the repo contract
+ run: bash .utils/scripts/check_repo_contract.sh
diff --git a/.gitignore b/.gitignore
index 43ee92a1..cbe0fa9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -489,3 +489,7 @@ gates/runnability-ci.tsv
# Per-course virtualenvs created by ./run.sh run.
.venv/
+
+# test coverage output
+.coverage
+coverage.xml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b4ec3b53..d3ef5a04 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -67,6 +67,12 @@ repos:
- repo: local
hooks:
+ - id: repo-contract
+ name: repo contract (CLAUDE.md Commands + test_changed)
+ entry: scripts/check_repo_contract.sh
+ language: system
+ pass_filenames: false
+ files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$
# Coursework deliverables are allowed under the two course trees;
# compiled output is allowed nowhere. See .binary-allowlist.
- id: no-binaries
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c8d1fb2..e8771c66 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -155,3 +155,12 @@ applies.
| `.binary-allowlist` | Path-scoped: deliverables allowed under the two course trees, compiled output allowed nowhere. |
| `DOCS-runnability.md` | Per-course run status, recorded separately for `local` and `ci`. |
| `TODO-*.md` | Open decisions. Each carries the removal marker and is deleted when the work lands. |
+
+## Commands
+
+- run: n/a: coursework archive; per course use `./run.sh run
`
+- test: `scripts/test_suites.sh`
+- test-changed: `scripts/test_changed.sh`
+- lint: `ruff check .`
+- coverage: `COVERAGE=1 scripts/test_suites.sh`
+- coverage-gaps: `coverage-gaps coverage.xml`
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..a74077ab
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,5 @@
+# finish gate hook: the root-level pytest cannot collect the ECRYPT sieve suite (relative CSVs), so
+# the shared gate calls this target instead (see scripts/test_suites.sh).
+.PHONY: cover
+cover:
+ COVERAGE=1 scripts/test_suites.sh
diff --git a/scripts/check_repo_contract.sh b/scripts/check_repo_contract.sh
new file mode 100755
index 00000000..84888585
--- /dev/null
+++ b/scripts/check_repo_contract.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+# ============================================================================
+# Fail if this repo breaks the shared repo contract.
+#
+# Thin delegate to the shared gate in ~/src/utils, which owns the contract
+# text. Copy this file to scripts/check_repo_contract.sh in a repo; never copy
+# the shared gate itself, or the shim would exec itself forever.
+# ============================================================================
+
+set -euo pipefail
+
+readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_repo_contract.sh"
+
+main() {
+ if [[ ! -x "$SHARED_GATE" ]]; then
+ echo "Error: shared repo-contract gate not found at $SHARED_GATE" >&2
+ echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
+ echo " UTILS_ROOT to where it lives." >&2
+ exit 1
+ fi
+
+ exec bash "$SHARED_GATE" "$@"
+}
+
+main "$@"
diff --git a/scripts/test_changed.sh b/scripts/test_changed.sh
new file mode 100755
index 00000000..1f96ca81
--- /dev/null
+++ b/scripts/test_changed.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+# ============================================================================
+# Run only the pytest suites whose directory has changes vs HEAD (staged,
+# unstaged and untracked). Quiet: failures plus a one-line summary per suite.
+# Changes to shared config (pyproject.toml, this repo's test scripts) run all
+# suites; changes outside any suite run nothing.
+# ============================================================================
+
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+readonly ROOT
+readonly SUITES=(Programming/ECOTE Programming/ECRYPT_PROJECT/sieve)
+
+changed="$(cd "$ROOT" && { git diff --name-only HEAD 2>/dev/null || true; git ls-files --others --exclude-standard; } | sort -u)"
+selected=()
+all=0
+for s in "${SUITES[@]}"; do
+ grep -q "^${s}/" <<<"$changed" && selected+=("$s")
+done
+grep -Eq '^(pyproject\.toml|scripts/test_suites\.sh|scripts/test_changed\.sh)$' <<<"$changed" && all=1
+
+if [[ $all -eq 1 ]]; then
+ echo "shared test config changed: running all suites"
+ exec "$ROOT/scripts/test_suites.sh"
+fi
+if [[ ${#selected[@]} -eq 0 ]]; then
+ echo "no changes in a pytest suite: nothing to test"
+ exit 0
+fi
+SUITE_DIRS="${selected[*]}" exec "$ROOT/scripts/test_suites.sh"
diff --git a/scripts/test_suites.sh b/scripts/test_suites.sh
new file mode 100755
index 00000000..6407fcc2
--- /dev/null
+++ b/scripts/test_suites.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+# ============================================================================
+# Run the pytest suites that exist in this archive, each from its own directory
+# (ECRYPT sieve reads its reference CSVs by relative path, so a repo-root
+# `pytest` cannot collect it). Extra args are passed to every pytest run.
+# Usage: scripts/test_suites.sh [pytest args]
+# COVERAGE=1 scripts/test_suites.sh # writes coverage.xml at repo root
+# ============================================================================
+
+set -euo pipefail
+
+ROOT="$(git rev-parse --show-toplevel)"
+readonly ROOT
+readonly SUITES=(Programming/ECOTE Programming/ECRYPT_PROJECT/sieve)
+
+run_suites() {
+ local -a suites=("$@") extra=()
+ [[ ${#suites[@]} -eq 0 ]] && suites=("${SUITES[@]}")
+ if [[ "${COVERAGE:-0}" == 1 ]]; then
+ export COVERAGE_FILE="$ROOT/.coverage"
+ rm -f "$COVERAGE_FILE" "$ROOT/coverage.xml"
+ fi
+ local rc=0 s
+ for s in "${suites[@]}"; do
+ if [[ "${COVERAGE:-0}" == 1 ]]; then
+ extra=(--cov="$ROOT/$s" --cov-branch --cov-append --cov-config="$ROOT/pyproject.toml")
+ fi
+ (cd "$ROOT/$s" && python3 -m pytest -q --tb=short "${extra[@]}" "${PYTEST_ARGS[@]}") || rc=$?
+ done
+ if [[ "${COVERAGE:-0}" == 1 ]]; then
+ (cd "$ROOT" && python3 -m coverage xml -o coverage.xml --rcfile=pyproject.toml >/dev/null)
+ fi
+ return "$rc"
+}
+
+PYTEST_ARGS=("$@")
+[[ -n "${SUITE_DIRS:-}" ]] && read -r -a SELECTED <<<"$SUITE_DIRS" || SELECTED=()
+run_suites "${SELECTED[@]}"
+exit $?