From 8e254dbef6577930f56645d6605bae99b90a3795 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 4 Oct 2026 16:58:12 +0200 Subject: [PATCH] repo contract: CLAUDE.md commands, test_changed, CI gate, AGENTS.md update Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/repo-contract.yml | 25 +++++++++++ .pre-commit-config.yaml | 6 +++ AGENTS.md | 27 ++++++----- scripts/check_repo_contract.sh | 26 +++++++++++ scripts/test_changed.sh | 70 +++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/repo-contract.yml create mode 100755 scripts/check_repo_contract.sh create mode 100755 scripts/test_changed.sh diff --git a/.github/workflows/repo-contract.yml b/.github/workflows/repo-contract.yml new file mode 100644 index 0000000..89d5597 --- /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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ce1850..5b6b9d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,6 +32,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)$ - id: no-noqa name: Block noqa comments entry: '(?i)#\s*(noqa|type:\s*ignore)' diff --git a/AGENTS.md b/AGENTS.md index d869297..6d6cd30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,21 +212,20 @@ and the root ring unit's `systemctl start|stop` lines live there. ## Commands -- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100` -- Phone tests: `cd phone_app && flutter test && flutter analyze` -- Walk the morning on the emulator: `phone_app/scripts/emu_morning.py` (`DOCS-morning-session.md`) -- Lint: `pre-commit run --all-files` -- Show the resolved alarm / the morning: `python -m wake_alarm._sync --show` (`--no-cache` live), `python -m wake_alarm._session --show` -- Show what the next tick would do: `python -m wake_alarm._trigger --dry-run` -- Watch it live: `journalctl --user -fu wake-alarm-trigger.service` -- Install: `bash install.sh` — Uninstall: `bash uninstall.sh` -- Gate the shell/system scripts: `bash scripts/vmbox/harness.sh` - (disposable Arch VM; see **`DOCS-vmbox.md`**) -- Ring the phone for a manual test: `lib/main_ring_probe.dart` (see - `DOCS-phone-alarm.md` — it starts a **real** alarm, and the new sequence is - what stops it) +- run: n/a: no single entry point; systemd timers (`install.sh`) drive it, inspect with `python -m wake_alarm._sync --show`, `python -m wake_alarm._session --show`, `python -m wake_alarm._trigger --dry-run` +- test: `python -m pytest -q wake_alarm/tests/ && cd phone_app && flutter test --reporter=compact` +- test-changed: `scripts/test_changed.sh` +- lint: `pre-commit run ruff --all-files && cd phone_app && flutter analyze --fatal-infos --fatal-warnings` +- coverage: `python -m pytest -q wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-report=xml --cov-fail-under=100` +- coverage-gaps: `coverage-gaps coverage.xml` -The dev venv lacks `gatelock`, so anything importing `_state` (including +Other: full lint gate `pre-commit run --all-files`; watch live +`journalctl --user -fu wake-alarm-trigger.service`; `bash install.sh` / +`bash uninstall.sh`; shell/system scripts gate `bash scripts/vmbox/harness.sh` +(disposable Arch VM, **`DOCS-vmbox.md`**); emulator morning +`phone_app/scripts/emu_morning.py` (`DOCS-morning-session.md`); ring the phone +via `lib/main_ring_probe.dart` (`DOCS-phone-alarm.md` — starts a **real** +alarm). The dev venv lacks `gatelock`: anything importing `_state` (incl. `_trigger`) needs `/usr/bin/python3`, not `.venv/bin/python`. ## Do NOT diff --git a/scripts/check_repo_contract.sh b/scripts/check_repo_contract.sh new file mode 100755 index 0000000..8488858 --- /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 0000000..589448c --- /dev/null +++ b/scripts/test_changed.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# ============================================================================ +# Run only the tests related to files changed vs HEAD (staged, unstaged and +# untracked). Quiet: failures plus a one-line summary. Falls back to the full +# suite when a change cannot be mapped to tests. Generated by repo_contract. +# ============================================================================ + +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +CHANGED=() +while IFS= read -r f; do + [[ -n "$f" && -e "$f" ]] && CHANGED+=("$f") +done < <({ git diff --name-only HEAD 2>/dev/null || true; git ls-files --others --exclude-standard; } | sort -u) + +pytest_run() { + if [[ -f uv.lock ]]; then uv run pytest "$@" + elif [[ -f poetry.lock ]]; then poetry run pytest "$@" + else python3 -m pytest "$@"; fi +} + +if grep -qs testmon pyproject.toml requirements*.txt; then + pytest_run --testmon -q --tb=short; exit $? +fi + +full=0 +dart=0 +dfull=0 +tests=() +dtests=() +for f in "${CHANGED[@]}"; do + case "$f" in + */conftest.py | conftest.py | pyproject.toml | requirements*.txt | pytest.ini | setup.cfg) full=1 ;; + phone_app/lib/*.dart) + rel="${f#phone_app/lib/}" + [[ -f "phone_app/test/${rel%.dart}_test.dart" ]] && dtests+=("test/${rel%.dart}_test.dart") + dart=1 + ;; + phone_app/test/*_test.dart) dtests+=("${f#phone_app/}"); dart=1 ;; + phone_app/pubspec.yaml | phone_app/pubspec.lock) dart=1; dfull=1 ;; + *.py) + base="$(basename "$f" .py)" + if [[ "$base" == test_* || "$base" == *_test ]]; then + tests+=("$f") + else + while IFS= read -r t; do tests+=("$t"); done \ + < <(git ls-files "*test_${base}.py" "*${base}_test.py") + fi + ;; + esac +done + +dart_run() { + if [[ $dfull -eq 1 || ${#dtests[@]} -eq 0 ]]; then (cd phone_app && flutter test --reporter=compact) + else (cd phone_app && flutter test --reporter=compact "${dtests[@]}"); fi +} + +if [[ $full -eq 0 && ${#CHANGED[@]} -eq 0 ]]; then echo "no changes vs HEAD: nothing to test"; exit 0; fi +if [[ $full -eq 0 && ( ${#tests[@]} -gt 0 || $dart -eq 1 ) ]]; then + rc=0 + [[ ${#tests[@]} -gt 0 ]] && { pytest_run -q --tb=short --no-cov "${tests[@]}" || rc=$?; } + [[ $dart -eq 1 ]] && { dart_run || rc=$?; } + exit $rc +fi +if [[ $full -eq 0 ]] && ! printf '%s\n' "${CHANGED[@]}" | grep -q '\.py$'; then + echo "no python changes: nothing to test"; exit 0 +fi +echo "no mapped tests: running full suite" +pytest_run -q --tb=short --no-cov