diff --git a/.github/scripts/create-labels.sh b/.github/scripts/create-labels.sh index 8386fae4..66f07ea9 100755 --- a/.github/scripts/create-labels.sh +++ b/.github/scripts/create-labels.sh @@ -62,6 +62,9 @@ create "scope: ci" "546E7A" "CI/CD workflows" create "scope: docs" "78909C" "Documentation" create "scope: dependencies" "90A4AE" "Dependency updates" +echo "==> Creating workflow labels..." +create "skip-regression-check" "9E9E9E" "Acknowledged: fix without regression test" + echo "==> Creating contributor labels..." create "contributor: new" "FFF9C4" "First-time contributor" create "contributor: regular" "FFE082" "2-5 merged PRs" diff --git a/.github/workflows/regression-test-check.yml b/.github/workflows/regression-test-check.yml new file mode 100644 index 00000000..18b8c76f --- /dev/null +++ b/.github/workflows/regression-test-check.yml @@ -0,0 +1,107 @@ +name: Regression Test Check + +on: + pull_request: + +jobs: + regression-test: + name: Regression test enforcement + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for regression tests + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + run: | + set -euo pipefail + + BASE_REF="origin/${{ github.event.pull_request.base.ref }}" + + # --- 1. Is this a fix PR? Check title first, then commit messages --- + IS_FIX=false + + if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$PR_TITLE"; then + IS_FIX=true + fi + + if [ "$IS_FIX" = false ]; then + COMMITS=$(git log --format='%s' "${BASE_REF}..HEAD") + if grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$COMMITS"; then + IS_FIX=true + fi + fi + + if [ "$IS_FIX" = false ]; then + echo "Not a fix PR — skipping regression test check." + exit 0 + fi + + echo "Fix PR detected." + + # --- 2. Skip label or commit message marker --- + if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then + echo "skip-regression-check label present — skipping." + exit 0 + fi + + COMMIT_BODIES=$(git log --format='%B' "${BASE_REF}..HEAD") + if grep -qF '[skip-regression-check]' <<< "$COMMIT_BODIES"; then + echo "[skip-regression-check] found in commit message — skipping." + exit 0 + fi + + # --- 3. Exempt static-only / docs-only changes --- + CHANGED_FILES=$(git diff --name-only "${BASE_REF}...HEAD") + + if [ -z "$CHANGED_FILES" ]; then + echo "No changed files — skipping." + exit 0 + fi + + ALL_EXEMPT=true + while IFS= read -r file; do + case "$file" in + src/channels/web/static/*) ;; + *.md) ;; + *) ALL_EXEMPT=false; break ;; + esac + done <<< "$CHANGED_FILES" + + if [ "$ALL_EXEMPT" = true ]; then + echo "All changes are static assets or docs — skipping." + exit 0 + fi + + # --- 4. Look for test changes --- + + # Fast path: new test attributes or test modules in added lines. + if git diff "${BASE_REF}...HEAD" -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then + echo "Test changes found in .rs files." + exit 0 + fi + + # Whole-function context: detect edits inside existing test functions. + if git diff "${BASE_REF}...HEAD" -W -- '*.rs' | awk ' + /^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 } + /^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 } + /^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 } + /^\+[^+]/ { has_add=1 } + END { if (has_test && has_add) found=1; exit !found } + '; then + echo "Test changes found in existing test functions." + exit 0 + fi + + if grep -qE '^tests/' <<< "$CHANGED_FILES"; then + echo "Test file changes found under tests/." + exit 0 + fi + + # --- 5. No tests found --- + echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible." + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 11f2effc..4b8b89b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,6 +321,8 @@ cargo check --all-features # all features ``` Dead code behind the wrong `#[cfg]` gate will only show up when building with a single feature. +**Regression test with every fix:** Every bug fix must include a test that would have caught the bug. Add a `#[test]` or `#[tokio::test]` that reproduces the original failure. Exempt: changes limited to `src/channels/web/static/` or `.md` files. Use `[skip-regression-check]` in commit message or PR label if genuinely not feasible. The `commit-msg` hook and CI workflow enforce this automatically. + **Zero clippy warnings policy:** Fix ALL clippy warnings before committing, including pre-existing ones in files you didn't change. Never leave warnings behind — treat `cargo clippy` output as a zero-tolerance gate. **Mechanical verification before committing:** Run these checks on changed files before committing: @@ -328,6 +330,7 @@ Dead code behind the wrong `#[cfg]` gate will only show up when building with a - `grep -rnE '\.unwrap\(|\.expect\(' ` -- no panics in production - `grep -rn 'super::' ` -- use `crate::` imports - If you fixed a pattern bug, `grep` for other instances of that pattern across `src/` +- Fix commits must include regression tests (enforced by `commit-msg` hook; bypass with `[skip-regression-check]`) ## Configuration diff --git a/scripts/commit-msg-regression.sh b/scripts/commit-msg-regression.sh new file mode 100755 index 00000000..a56fdd00 --- /dev/null +++ b/scripts/commit-msg-regression.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# commit-msg hook: require regression tests for fix commits. +# +# Installed by scripts/dev-setup.sh as .git/hooks/commit-msg. +# Bypass with [skip-regression-check] in the commit message. + +set -euo pipefail + +MSG_FILE="$1" +FIRST_LINE=$(head -1 "$MSG_FILE") + +# --- 1. Is this a fix commit? --- +if ! grep -qiE '^(fix(\(.*\))?|hotfix|bugfix):' <<< "$FIRST_LINE"; then + exit 0 +fi + +# --- 2. Skip marker --- +if grep -qF '[skip-regression-check]' "$MSG_FILE"; then + exit 0 +fi + +# --- 3. Exempt static-only / docs-only changes --- +# Get staged files (commit-msg runs after staging is finalized). +STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR) + +if [ -z "$STAGED_FILES" ]; then + exit 0 +fi + +ALL_EXEMPT=true +while IFS= read -r file; do + case "$file" in + src/channels/web/static/*) ;; + *.md) ;; + *) ALL_EXEMPT=false; break ;; + esac +done <<< "$STAGED_FILES" + +if [ "$ALL_EXEMPT" = true ]; then + exit 0 +fi + +# --- 4. Look for test changes in staged .rs files --- + +# Fast path: new test attributes or test modules in added lines. +if git diff --cached -U0 -- '*.rs' | grep -qE '^\+.*(#\[test\]|#\[tokio::test\]|#\[cfg\(test\)\]|mod tests)'; then + exit 0 +fi + +# Whole-function context: detect edits inside existing test functions. +# -W shows the full enclosing function, so #[test] appears in context +# lines when changes are inside a test function. +if git diff --cached -W -- '*.rs' | awk ' + /^@@/ { if (has_test && has_add) { found=1; exit } has_test=0; has_add=0 } + /^ .*#\[test\]/ || /^ .*#\[tokio::test\]/ || /^ .*#\[cfg\(test\)\]/ || /^ .*mod tests/ { has_test=1 } + /^\+.*#\[test\]/ || /^\+.*#\[tokio::test\]/ || /^\+.*#\[cfg\(test\)\]/ || /^\+.*mod tests/ { has_test=1 } + /^\+[^+]/ { has_add=1 } + END { if (has_test && has_add) found=1; exit !found } +'; then + exit 0 +fi + +# Also check for new/modified files under tests/ +if grep -qE '^tests/' <<< "$STAGED_FILES"; then + exit 0 +fi + +# --- 5. No test found — block the commit --- +echo "" +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ REGRESSION TEST REQUIRED ║" +echo "║ ║" +echo "║ This commit looks like a bug fix but has no test changes. ║" +echo "║ Every fix should include a test that reproduces the bug. ║" +echo "║ ║" +echo "║ Options: ║" +echo "║ • Add a #[test] or #[tokio::test] that catches the bug ║" +echo "║ • Add [skip-regression-check] to your commit message ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" +exit 1 diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index d052c9d1..7293f8d1 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -24,14 +24,14 @@ if ! command -v rustup &>/dev/null; then echo "ERROR: rustup not found. Install from https://rustup.rs" exit 1 fi -echo "[1/5] rustup found: $(rustup --version 2>/dev/null | head -1)" +echo "[1/6] rustup found: $(rustup --version 2>/dev/null | head -1)" # 2. Add WASM target (required by build.rs for channel compilation) -echo "[2/5] Adding wasm32-wasip2 target..." +echo "[2/6] Adding wasm32-wasip2 target..." rustup target add wasm32-wasip2 # 3. Install wasm-tools (required by build.rs for WASM component model) -echo "[3/5] Installing wasm-tools..." +echo "[3/6] Installing wasm-tools..." if command -v wasm-tools &>/dev/null; then echo " wasm-tools already installed: $(wasm-tools --version)" else @@ -39,13 +39,25 @@ else fi # 4. Verify the project compiles -echo "[4/5] Running cargo check..." +echo "[4/6] Running cargo check..." cargo check # 5. Run tests using libsql temp DB (no Docker/external DB needed) -echo "[5/5] Running tests (no external DB required)..." +echo "[5/6] Running tests (no external DB required)..." cargo test +# 6. Install git hooks +echo "[6/6] Installing git hooks..." +HOOKS_DIR=$(git rev-parse --git-path hooks 2>/dev/null) || true +if [ -n "$HOOKS_DIR" ]; then + mkdir -p "$HOOKS_DIR" + SCRIPT_ABS="$(cd "$(dirname "$0")" && pwd)/commit-msg-regression.sh" + ln -sf "$SCRIPT_ABS" "$HOOKS_DIR/commit-msg" + echo " commit-msg hook installed (regression test enforcement)" +else + echo " Skipped: not a git repository" +fi + echo "" echo "=== Setup complete ===" echo ""