From 83cd2e2f6a272636f16bd57f1546a62f7347f9f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 19:40:49 +0200 Subject: [PATCH] feat: enforce print lint (T20) and test-file reminder hooks - Enable Ruff flake8-print (T20) in generated projects and this repo; allow print in tests/, scripts/, and bump_version.py via per-file-ignores. - Extend pre-config-protection to block silencing T20/T201 in the global ruff ignore list. - Add pre-write-src-test-reminder.sh (PreToolUse): non-blocking warning when tests//test_.py is missing for top-level src//.py. - Register the hook in template and root .claude/settings.json; document in hook READMEs and python/hooks.md. - Add test_generated_pyproject_ruff_includes_print_rules. Made-with: Cursor --- .claude/hooks/README.md | 1 + .claude/hooks/pre-config-protection.sh | 4 +- .claude/hooks/pre-write-src-test-reminder.sh | 68 +++++++++++++++++++ .claude/settings.json | 10 +++ pyproject.toml | 30 ++++---- template/.claude/hooks/README.md | 1 + .../.claude/hooks/pre-config-protection.sh | 4 +- .../hooks/pre-write-src-test-reminder.sh | 68 +++++++++++++++++++ template/.claude/rules/python/hooks.md | 20 ++++-- template/.claude/settings.json | 10 +++ template/pyproject.toml.jinja | 5 +- tests/test_template.py | 15 ++++ 12 files changed, 213 insertions(+), 23 deletions(-) create mode 100755 .claude/hooks/pre-write-src-test-reminder.sh create mode 100755 template/.claude/hooks/pre-write-src-test-reminder.sh diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md index 1ed715d..c10a05f 100644 --- a/.claude/hooks/README.md +++ b/.claude/hooks/README.md @@ -272,6 +272,7 @@ exit 0 | `pre-bash-commit-quality.sh` | PreToolUse | Bash | Secret/debug scan before `git commit` | | `pre-config-protection.sh` | PreToolUse | Write\|Edit\|MultiEdit | Block weakening ruff/pyright config edits | | `pre-protect-uv-lock.sh` | PreToolUse | Write\|Edit | Block direct edits to `uv.lock` | +| `pre-write-src-test-reminder.sh` | PreToolUse | Write\|Edit | Warn if `tests//test_.py` missing for top-level `src//.py` | | `pre-write-doc-file-warning.sh` | PreToolUse | Write | Block `.md` files outside `docs/` | | `pre-write-jinja-syntax.sh` | PreToolUse | Write | Validate Jinja2 syntax before writing | | `pre-suggest-compact.sh` | PreToolUse | Edit\|Write | Suggest `/compact` every 50 operations | diff --git a/.claude/hooks/pre-config-protection.sh b/.claude/hooks/pre-config-protection.sh index e1a8d97..3e5d0bc 100755 --- a/.claude/hooks/pre-config-protection.sh +++ b/.claude/hooks/pre-config-protection.sh @@ -8,7 +8,7 @@ # # BLOCK conditions: # - typeCheckingMode = "off" or "basic" (downgrade from strict / standard) -# - Core ruff rule category added to ignore list: D, E, F, I, B +# - Core ruff rule category added to ignore list: D, E, F, I, B, T20, T201 # # WARN conditions: # - Any edit that touches [tool.ruff.lint] select/ignore in pyproject.toml @@ -67,7 +67,7 @@ if re.search(r'typeCheckingMode\s*=\s*"(off|basic)"', new_content): ignore_match = re.search(r'ignore\s*=\s*\[([^\]]*)\]', new_content, re.DOTALL) if ignore_match: ignore_block = ignore_match.group(1) - for rule in ("D", "E", "F", "I", "B"): + for rule in ("D", "E", "F", "I", "B", "T20", "T201"): if re.search(rf'["\x27]{re.escape(rule)}["\x27]', ignore_block): print(f'BLOCK:Core ruff rule "{rule}" added to ignore list — fix code instead of suppressing rules') sys.exit(0) diff --git a/.claude/hooks/pre-write-src-test-reminder.sh b/.claude/hooks/pre-write-src-test-reminder.sh new file mode 100755 index 0000000..81a044a --- /dev/null +++ b/.claude/hooks/pre-write-src-test-reminder.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Claude PreToolUse hook — Write|Edit +# Remind to add a pytest module when touching a top-level package source file. +# +# Scope: only paths matching src//.py (exactly one directory between +# src/ and the file), excluding __init__.py. Nested layouts such as +# src//common/foo.py are skipped — they are often covered by shared tests +# (e.g. test_support.py). +# +# Reference : https://github.com/affaan-m/everything-claude-code/blob/main/hooks/README.md +# (recipe: Require test files alongside new source files — pytest layout) +# Exits : 0 always (non-blocking; warnings on stderr only) + +set -uo pipefail + +INPUT=$(cat) + +FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' +import json, sys + +data = json.load(sys.stdin) +print(data.get("tool_input", {}).get("file_path", "")) +') || { + echo "$INPUT" + exit 0 +} + +[[ "$FILE_PATH" == *.py ]] || { + echo "$INPUT" + exit 0 +} + +# Normalise for matching (JSON paths may use backslashes on Windows clients). +FP="${FILE_PATH//\\//}" +FP="${FP#./}" + +if [[ ! "$FP" =~ ^(.*/)?src/([^/]+)/([^/]+)\.py$ ]]; then + echo "$INPUT" + exit 0 +fi + +PKG="${BASH_REMATCH[2]}" +MOD="${BASH_REMATCH[3]}" + +if [[ "$MOD" == "__init__" ]]; then + echo "$INPUT" + exit 0 +fi + +EXPECTED_TEST="tests/${PKG}/test_${MOD}.py" + +if [[ -f "$EXPECTED_TEST" ]]; then + echo "$INPUT" + exit 0 +fi + +echo "┌─ Test module reminder" >&2 +echo "│" >&2 +echo "│ Source : $FP" >&2 +echo "│ No file: $EXPECTED_TEST" >&2 +echo "│" >&2 +echo "│ Add tests under tests// (pytest: test_.py) or extend an" >&2 +echo "│ existing test module that imports this code." >&2 +echo "│ Nested src paths (e.g. src//common/…) are not checked." >&2 +echo "└─" >&2 + +echo "$INPUT" +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index eeda334..83c6018 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -86,6 +86,16 @@ ], "description": "Block direct edits to uv.lock — must be regenerated via uv lock or just update" }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/pre-write-src-test-reminder.sh" + } + ], + "description": "Remind to add tests//test_.py for top-level src//.py" + }, { "matcher": "Write", "hooks": [ diff --git a/pyproject.toml b/pyproject.toml index df095bd..3dec7de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,20 +29,22 @@ target-version = "py311" [tool.ruff.lint] select = [ - "E", # pycodestyle errors - "F", # pyflakes - "I", # isort - "UP", # pyupgrade - "B", # flake8-bugbear - "SIM", # flake8-simplify - "C4", # flake8-comprehensions - "RUF", # ruff-specific rules - "D", # pydocstyle — enforce Google-style docstrings - "C90", # McCabe complexity - "PERF", # perflint — catch performance anti-patterns + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "C4", # flake8-comprehensions + "RUF", # ruff-specific rules + "D", # pydocstyle — enforce Google-style docstrings + "C90", # McCabe complexity + "PERF", # perflint — catch performance anti-patterns + "T20", # flake8-print — discourage print() in non-test code ] + ignore = [ - "E501", # line too long (handled by formatter) + "E501", # line too long (handled by formatter) ] [tool.ruff.lint.pydocstyle] @@ -52,8 +54,8 @@ convention = "google" max-complexity = 10 [tool.ruff.lint.per-file-ignores] -"tests/**" = ["D"] # test functions don't require docstrings -"scripts/**" = ["D"] # utility scripts don't require docstrings +"tests/**" = ["D", "T20"] # test functions don't require docstrings; print OK in tests +"scripts/**" = ["D", "T20"] # utility scripts may use print [tool.pytest.ini_options] minversion = "8.0" diff --git a/template/.claude/hooks/README.md b/template/.claude/hooks/README.md index 3d91ca8..00b098e 100644 --- a/template/.claude/hooks/README.md +++ b/template/.claude/hooks/README.md @@ -215,6 +215,7 @@ exit 0 | `pre-bash-commit-quality.sh` | PreToolUse | Bash | Secret/debug scan before commit | | `pre-config-protection.sh` | PreToolUse | Write\|Edit\|MultiEdit | Block weakening ruff/pyright config | | `pre-protect-uv-lock.sh` | PreToolUse | Write\|Edit | Block direct edits to `uv.lock` | +| `pre-write-src-test-reminder.sh` | PreToolUse | Write\|Edit | Warn if `tests//test_.py` missing for top-level `src//.py` | ## Adding a new hook diff --git a/template/.claude/hooks/pre-config-protection.sh b/template/.claude/hooks/pre-config-protection.sh index e1a8d97..3e5d0bc 100755 --- a/template/.claude/hooks/pre-config-protection.sh +++ b/template/.claude/hooks/pre-config-protection.sh @@ -8,7 +8,7 @@ # # BLOCK conditions: # - typeCheckingMode = "off" or "basic" (downgrade from strict / standard) -# - Core ruff rule category added to ignore list: D, E, F, I, B +# - Core ruff rule category added to ignore list: D, E, F, I, B, T20, T201 # # WARN conditions: # - Any edit that touches [tool.ruff.lint] select/ignore in pyproject.toml @@ -67,7 +67,7 @@ if re.search(r'typeCheckingMode\s*=\s*"(off|basic)"', new_content): ignore_match = re.search(r'ignore\s*=\s*\[([^\]]*)\]', new_content, re.DOTALL) if ignore_match: ignore_block = ignore_match.group(1) - for rule in ("D", "E", "F", "I", "B"): + for rule in ("D", "E", "F", "I", "B", "T20", "T201"): if re.search(rf'["\x27]{re.escape(rule)}["\x27]', ignore_block): print(f'BLOCK:Core ruff rule "{rule}" added to ignore list — fix code instead of suppressing rules') sys.exit(0) diff --git a/template/.claude/hooks/pre-write-src-test-reminder.sh b/template/.claude/hooks/pre-write-src-test-reminder.sh new file mode 100755 index 0000000..81a044a --- /dev/null +++ b/template/.claude/hooks/pre-write-src-test-reminder.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Claude PreToolUse hook — Write|Edit +# Remind to add a pytest module when touching a top-level package source file. +# +# Scope: only paths matching src//.py (exactly one directory between +# src/ and the file), excluding __init__.py. Nested layouts such as +# src//common/foo.py are skipped — they are often covered by shared tests +# (e.g. test_support.py). +# +# Reference : https://github.com/affaan-m/everything-claude-code/blob/main/hooks/README.md +# (recipe: Require test files alongside new source files — pytest layout) +# Exits : 0 always (non-blocking; warnings on stderr only) + +set -uo pipefail + +INPUT=$(cat) + +FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' +import json, sys + +data = json.load(sys.stdin) +print(data.get("tool_input", {}).get("file_path", "")) +') || { + echo "$INPUT" + exit 0 +} + +[[ "$FILE_PATH" == *.py ]] || { + echo "$INPUT" + exit 0 +} + +# Normalise for matching (JSON paths may use backslashes on Windows clients). +FP="${FILE_PATH//\\//}" +FP="${FP#./}" + +if [[ ! "$FP" =~ ^(.*/)?src/([^/]+)/([^/]+)\.py$ ]]; then + echo "$INPUT" + exit 0 +fi + +PKG="${BASH_REMATCH[2]}" +MOD="${BASH_REMATCH[3]}" + +if [[ "$MOD" == "__init__" ]]; then + echo "$INPUT" + exit 0 +fi + +EXPECTED_TEST="tests/${PKG}/test_${MOD}.py" + +if [[ -f "$EXPECTED_TEST" ]]; then + echo "$INPUT" + exit 0 +fi + +echo "┌─ Test module reminder" >&2 +echo "│" >&2 +echo "│ Source : $FP" >&2 +echo "│ No file: $EXPECTED_TEST" >&2 +echo "│" >&2 +echo "│ Add tests under tests// (pytest: test_.py) or extend an" >&2 +echo "│ existing test module that imports this code." >&2 +echo "│ Nested src paths (e.g. src//common/…) are not checked." >&2 +echo "└─" >&2 + +echo "$INPUT" +exit 0 diff --git a/template/.claude/rules/python/hooks.md b/template/.claude/rules/python/hooks.md index de7f1c9..bfccfb6 100644 --- a/template/.claude/rules/python/hooks.md +++ b/template/.claude/rules/python/hooks.md @@ -13,7 +13,7 @@ ### What the hook checks 1. **ruff check** — all active rule sets including `D` (docstrings), `C90` (complexity), - `PERF` (performance anti-patterns). + `PERF` (performance anti-patterns), `T20` / `T201` (no `print()` in app code). 2. **basedpyright** — type correctness in strict mode. Example output: @@ -42,9 +42,10 @@ Fix all violations before moving to the next file. The `pre-bash-block-no-verify.sh` hook prevents `git commit --no-verify`. -## `print()` warning +## `print()` enforcement -`print()` in `src/` is a ruff violation (`T201`). Use structlog: +Ruff includes **`T20`** (flake8-print) in `[tool.ruff.lint] select`. `print()` in +application code is reported as **`T201`**. Use structlog: ```python # Wrong @@ -55,7 +56,18 @@ log = structlog.get_logger() log.info("processing_order", order_id=order_id) ``` -`print()` is permitted in `scripts/` and test files. +`print()` is permitted in `scripts/`, `tests/**`, and `src/**/bump_version.py` (per-file +ignores in `pyproject.toml`). + +## Top-level module test reminder (PreToolUse) + +| Hook | Trigger | What it does | +|------|---------|----------------| +| `pre-write-src-test-reminder.sh` | `Write` or `Edit` on `src//.py` | Warns if `tests//test_.py` is missing | + +Only **top-level package modules** are checked (`src//.py`, excluding +`__init__.py`). Nested packages (for example `src//common/foo.py`) are skipped, +since those modules are often covered by shared test modules such as `test_support.py`. ## Type-checking configuration diff --git a/template/.claude/settings.json b/template/.claude/settings.json index 3207c19..0b02b63 100644 --- a/template/.claude/settings.json +++ b/template/.claude/settings.json @@ -72,6 +72,16 @@ } ], "description": "Block direct edits to uv.lock — must be regenerated via uv lock or just update" + }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/pre-write-src-test-reminder.sh" + } + ], + "description": "Remind to add tests//test_.py for top-level src//.py" } ], "PostToolUse": [ diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index 4c94b1f..78c2a26 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -107,6 +107,7 @@ select = [ "D", # pydocstyle — enforce Google-style docstrings on public symbols "C90", # McCabe complexity — flag functions above max-complexity "PERF", # perflint — catch performance anti-patterns + "T20", # flake8-print — no print() in application code (use structlog) ] ignore = [ @@ -120,7 +121,9 @@ convention = "google" max-complexity = 10 [tool.ruff.lint.per-file-ignores] -"tests/**" = ["ARG", "D"] # tests don't require docstrings +"tests/**" = ["ARG", "D", "T20"] # tests don't require docstrings; print() OK in tests +"scripts/**" = ["T20"] # CLI-style scripts may use print +"src/**/bump_version.py" = ["T20"] # emits version on stdout for tooling [tool.ruff.lint.isort] known-first-party = ["{{ package_name }}"] diff --git a/tests/test_template.py b/tests/test_template.py index cb4b72e..95c9388 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -879,6 +879,21 @@ def test_generated_pyproject_basedpyright_standard_mode(tmp_path: Path) -> None: assert "reportMissingImports = true" in raw +def test_generated_pyproject_ruff_includes_print_rules(tmp_path: Path) -> None: + """Generated projects should enable Ruff T20 (flake8-print) with sensible per-file ignores.""" + test_dir = tmp_path / "ruff_t20" + copy_with_data(test_dir, {"project_name": "Ruff T20", "include_docs": False}) + data = tomllib.loads((test_dir / "pyproject.toml").read_text(encoding="utf-8")) + ruff_lint = cast(Mapping[str, object], cast(Mapping[str, object], data["tool"])["ruff"]) + lint = cast(Mapping[str, object], ruff_lint["lint"]) + select = cast(list[str], lint["select"]) + assert "T20" in select + per_file = cast(Mapping[str, list[str]], lint["per-file-ignores"]) + assert "T20" in per_file["tests/**"] + assert "T20" in per_file["scripts/**"] + assert "T20" in per_file["src/**/bump_version.py"] + + def test_generated_pre_commit_includes_detect_secrets(tmp_path: Path) -> None: """Pre-commit config in generated projects should run detect-secrets with a baseline.""" test_dir = tmp_path / "secrets_hook"