From dadc2b3e6fab640e2e05ad10e8642c5b2c0721c0 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 4 Oct 2026 16:37:41 +0200 Subject: [PATCH] repo contract: CLAUDE.md commands, test_changed, CI gate; Makefile cover hook, ignore coverage output Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/repo-contract.yml | 25 ++++++++++++++++++ .gitignore | 4 +++ .pre-commit-config.yaml | 6 +++++ CLAUDE.md | 9 +++++++ Makefile | 5 ++++ scripts/check_repo_contract.sh | 26 +++++++++++++++++++ scripts/test_changed.sh | 32 +++++++++++++++++++++++ scripts/test_suites.sh | 40 +++++++++++++++++++++++++++++ 8 files changed, 147 insertions(+) create mode 100644 .github/workflows/repo-contract.yml create mode 100644 Makefile create mode 100755 scripts/check_repo_contract.sh create mode 100755 scripts/test_changed.sh create mode 100755 scripts/test_suites.sh 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 $?