From f0623d0c4470a3bc0dbfd0beba8d06b96166bfb0 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 4 Oct 2026 17:13:21 +0200 Subject: [PATCH] repo contract: CLAUDE.md commands, test_changed, CI gate Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/repo-contract.yml | 25 ++++++++++ .pre-commit-config.yaml | 6 +++ AGENTS.md | 15 +++--- app/pubspec.yaml | 2 +- scripts/check_repo_contract.sh | 26 +++++++++++ scripts/test_changed.sh | 72 +++++++++++++++++++++++++++++ 6 files changed, 137 insertions(+), 9 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 111f03c..45f8582 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -249,3 +249,9 @@ repos: pass_filenames: false always_run: true stages: [pre-push] + - 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)$ diff --git a/AGENTS.md b/AGENTS.md index 0b05116..22fe61b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,17 +16,16 @@ catch-up rule). ## Commands -- Tests: `python -m pytest diet_guard/tests/ --cov=diet_guard --cov-branch --cov-fail-under=100` -- Lint: `pre-commit run --all-files` -- Test the lock (safe, closeable): `python -m diet_guard gate --demo` - One sync tick: `python -m diet_guard sync` - Averages: `python -m diet_guard averages`; Body: `body`, `weight KG`, `profile`, `plan-week` - Prune dead sync peers (dry run): `python -m diet_guard prune-peers` -- Install for production: `bash install.sh` -- App tests: `cd app && flutter test` -- Desktop app: `cd app && bash run.sh` -- Install desktop app: `cd app && bash install_arch.sh` -- Phone build: `cd app && flutter build apk --release` +- Install: `bash install.sh` (production); app: `cd app && bash install_arch.sh`; run app: `bash run.sh`; phone: `flutter build apk --release` +- run: `python -m diet_guard gate --demo` +- test: `python -m pytest -n auto -p no:sugar -q` (python, 100% branch bar from pyproject) then `cd app && flutter test --reporter=compact` +- test-changed: `scripts/test_changed.sh` (maps py and app/ dart changes to their tests, no coverage) +- lint: `pre-commit run --all-files` +- coverage: `python -m pytest -n auto -p no:sugar -q` (writes coverage.lcov) +- coverage-gaps: `coverage-gaps coverage.lcov` ## Scheduling diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5aa3d2c..af742ee 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.99+1 +version: 1.0.100+1 environment: sdk: ^3.12.2 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..ebff096 --- /dev/null +++ b/scripts/test_changed.sh @@ -0,0 +1,72 @@ +#!/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) + +# --no-cov: a subset cannot meet the 100% bar, and the term-missing table is noise. +pytest_run() { + local py=python3 + [[ -x .venv/bin/python ]] && py=.venv/bin/python + "$py" -m pytest --no-cov -p no:sugar "$@" +} + +# Dart changes map to app/test/_test.dart; any other app/ change runs the app suite. +flutter_run() { + (cd app && flutter test --reporter=compact "$@") +} + +if grep -qs testmon pyproject.toml requirements*.txt; then + pytest_run --testmon -q --tb=short; exit $? +fi + +full=0 +tests=() +dart_tests=() +dart_full=0 +for f in "${CHANGED[@]}"; do + case "$f" in + */conftest.py | conftest.py | pyproject.toml | requirements*.txt | pytest.ini | setup.cfg) full=1 ;; + app/test/*_test.dart) dart_tests+=("${f#app/}") ;; + app/lib/*.dart) + base="$(basename "$f" .dart)" + while IFS= read -r t; do dart_tests+=("${t#app/}"); done \ + < <(git ls-files "app/test/*${base}_test.dart") + [[ ${#dart_tests[@]} -eq 0 ]] && dart_full=1 + ;; + app/pubspec.yaml | app/pubspec.lock) dart_full=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 + +if [[ ${#CHANGED[@]} -eq 0 ]]; then echo "no changes vs HEAD: nothing to test"; exit 0; fi +rc=0 +if [[ $dart_full -eq 1 ]]; then flutter_run || rc=1 +elif [[ ${#dart_tests[@]} -gt 0 ]]; then flutter_run "${dart_tests[@]}" || rc=1; fi +if [[ $full -eq 1 ]]; then + echo "config changed: running full python suite"; pytest_run -q -n auto || rc=1 +elif [[ ${#tests[@]} -gt 0 ]]; then pytest_run -q --tb=short "${tests[@]}" || rc=1 +elif printf '%s\n' "${CHANGED[@]}" | grep -q '\.py$'; then + echo "no mapped python tests: running full python suite"; pytest_run -q -n auto --tb=short || rc=1 +elif [[ $dart_full -eq 0 && ${#dart_tests[@]} -eq 0 ]]; then + echo "no python/dart changes: nothing to test" +fi +exit "$rc"