diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index daf5e37..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,143 +0,0 @@ -# .claude/ — Claude Code Configuration (Meta-Repo) - -This directory contains all Claude Code configuration for **this Copier template repository** -(the meta-repo). It is active when you are developing the template itself. - -> [!IMPORTANT] -> **Dual-hierarchy:** This repo has two parallel `.claude/` trees: -> - `.claude/` ← active when **developing this template** (you are here) -> - `template/.claude/` ← rendered into every **generated project** -> -> When adding or modifying hooks, commands, or rules, decide which tree (or both) the -> change belongs in. See `rules/README.md` for the split rules. - -## Directory layout - -``` -.claude/ -├── CLAUDE.md ← this file -├── settings.json ← hook registrations and permission allow/deny lists -├── hooks/ ← shell hook scripts -│ ├── README.md ← full hook developer guide (events, matchers, exit codes, templates) -│ └── *.sh ← individual hook scripts -├── commands/ ← slash command prompt files -│ └── *.md ← one file per slash command -├── skills/ ← Agent skills (TDD workflow, test planner, test quality reviewer, …) -└── rules/ ← AI coding rules - ├── README.md ← rule developer guide (structure, priority, dual-hierarchy) - ├── common/ ← language-agnostic rules - ├── python/ ← Python-specific rules - ├── jinja/ ← Jinja2 rules (meta-repo only) - ├── bash/ ← Bash rules - ├── markdown/ ← Markdown placement and authoring rules - ├── yaml/ ← YAML conventions for copier.yml + workflows (meta-repo only) - └── copier/ ← Copier template conventions (meta-repo only) - └── template-conventions.md -``` - -## `settings.json` — permissions and hooks - -The `settings.json` file: -- **`permissions.allow`** — Bash commands Claude is allowed to run without user approval - (`just:*`, `uv:*`, `copier:*`, standard git read commands, `python3:*`). -- **`permissions.deny`** — Bash commands that are always blocked (`git push`, `uv publish`, - `git commit --no-verify`, `git push --force`). -- **`hooks`** — lifecycle hook registrations (see hooks/ section below). - -## hooks/ - -Shell scripts that integrate with Claude Code's lifecycle events. See `hooks/README.md` -for the full developer guide. - -### Hooks registered in this tree - -| Script | Event | Matcher | Purpose | -|---|---|---|---| -| `session-start-bootstrap.sh` | SessionStart | * | Toolchain status + previous session snapshot | -| `pre-bash-block-no-verify.sh` | PreToolUse | Bash | Block `--no-verify` in git commands | -| `pre-bash-git-push-reminder.sh` | PreToolUse | Bash | Warn to run `just review` before push | -| `pre-bash-commit-quality.sh` | PreToolUse | Bash | Scan staged `.py` files for secrets/debug markers | -| `pre-bash-coverage-gate.sh` | PreToolUse | Bash | Warn before `git commit` if coverage below threshold | -| `pre-config-protection.sh` | PreToolUse | Write\|Edit\|MultiEdit | Block weakening ruff/basedpyright config edits | -| `pre-protect-uv-lock.sh` | PreToolUse | Write\|Edit | Block direct edits to `uv.lock` | -| `pre-write-src-require-test.sh` | PreToolUse | Write\|Edit | Block if the matching test file does not exist (strict TDD) | -| `pre-write-src-test-reminder.sh` | PreToolUse | Write\|Edit | Warn if test file missing for new source module (optional alternative to strict TDD) | -| `pre-write-doc-file-warning.sh` | PreToolUse | Write | Block `.md` files written outside `docs/` | -| `pre-write-jinja-syntax.sh` | PreToolUse | Write | Validate Jinja2 syntax before writing `.jinja` files | -| `pre-suggest-compact.sh` | PreToolUse | Edit\|Write | Suggest `/compact` every ~50 operations | -| `pre-compact-save-state.sh` | PreCompact | * | Snapshot git state before context compaction | -| `post-edit-python.sh` | PostToolUse | Edit\|Write | ruff + basedpyright after every `.py` edit | -| `post-edit-jinja.sh` | PostToolUse | Edit\|Write | Jinja2 syntax check after every `.jinja` edit | -| `post-edit-markdown.sh` | PostToolUse | Edit | Warn if existing `.md` edited outside `docs/` | -| `post-edit-copier-migration.sh` | PostToolUse | Edit\|Write | Migration checklist after `copier.yml` edits | -| `post-edit-template-mirror.sh` | PostToolUse | Edit\|Write | Remind to mirror `template/.claude/` ↔ root | -| `post-edit-refactor-test-guard.sh` | PostToolUse | Edit\|Write | Remind to run tests after several `src/` or `scripts/` edits | -| `post-bash-pr-created.sh` | PostToolUse | Bash | Log PR URL after `gh pr create` | -| `stop-session-end.sh` | Stop | * | Persist session state JSON | -| `stop-evaluate-session.sh` | Stop | * | Extract reusable patterns from transcript | -| `stop-cost-tracker.sh` | Stop | * | Accumulate session token costs | -| `stop-desktop-notify.sh` | Stop | * | macOS desktop notification on completion | - -### Key differences vs `template/.claude/hooks/` - -The template hooks are a **subset** — generated projects do not need: -- `SessionStart` hooks (no session state tracking) -- `pre-write-doc-file-warning.sh` (no doc-file restriction) -- `pre-write-jinja-syntax.sh` (no Jinja files in generated projects) -- `pre-suggest-compact.sh` -- `pre-compact-save-state.sh` -- `post-edit-jinja.sh` -- `post-edit-copier-migration.sh` -- `post-edit-template-mirror.sh` -- `post-bash-pr-created.sh` -- All `stop-*` hooks - -## commands/ - -One Markdown file per slash command. Claude Code reads these files when you invoke -`/command-name`. The filename (without `.md`) is the slash command name. - -| Command | Purpose | -|---|---| -| `/review` | Full pre-merge checklist (lint + types + docstrings + coverage + symbol scan) | -| `/coverage` | Run coverage, identify gaps, write missing tests | -| `/docs-check` | Audit and repair Google-style docstrings | -| `/standards` | Consolidated pass/fail report — "ready to merge?" gate | -| `/update-claude-md` | Sync CLAUDE.md against pyproject.toml + justfile to prevent drift | -| `/generate` | Render the template into `/tmp/test-output` and inspect it | -| `/release` | Orchestrate a new release: CI → version bump → tag → push | -| `/validate-release` | Verify release prerequisites (clean tree, CI passing, correct tag) | -| `/ci` | Run `just ci` and report results | -| `/test` | Run `just test` and summarise failures | -| `/dependency-check` | Validate `uv.lock` is committed, in sync, and not stale | -| `/tdd-red` | Validate RED phase: confirm a test fails for the right reason | -| `/tdd-green` | Validate GREEN phase: confirm the test passes with no regressions | -| `/ci-fix` | Autonomous CI fixer: diagnose failures, apply fixes, re-run until green | - -## rules/ - -Plain Markdown files documenting coding standards. See `rules/README.md` for the -full guide on rule priority, the dual-hierarchy, and how to write new rules. - -### Rule directories in this tree (meta-repo) - -| Directory | Scope | -|---|---| -| `common/` | Language-agnostic: coding-style, git-workflow, testing, security, development-workflow, code-review | -| `python/` | Python: coding-style, testing, patterns, security, hooks | -| `jinja/` | Jinja2: coding-style, testing — **meta-repo only** | -| `bash/` | Bash: coding-style, security | -| `markdown/` | Markdown placement and authoring conventions | -| `yaml/` | YAML conventions for `copier.yml` and GitHub Actions — **meta-repo only** | -| `copier/` | Copier template conventions — **meta-repo only** | - -Rules in `jinja/`, `yaml/`, and `copier/` are **not** propagated to generated projects. - -## Adding new hooks, commands, or rules - -1. **Hook** — write the script in `hooks/`, register it in `settings.json`, test with a - sample JSON payload. Mirror to `template/.claude/hooks/` if relevant for generated projects. -2. **Command** — create a `.md` file in `commands/`. Mirror as `.md.jinja` in - `template/.claude/commands/` if the command is useful in generated projects. -3. **Rule** — create a `.md` file in the appropriate `rules/` subdirectory. Mirror to - `template/.claude/rules/` if the rule applies to generated projects too. diff --git a/.claude/commands/ci-fix.md b/.claude/commands/ci-fix.md index 2744369..64a22e0 100644 --- a/.claude/commands/ci-fix.md +++ b/.claude/commands/ci-fix.md @@ -1,3 +1,10 @@ +--- +description: Diagnose and fix all CI failures autonomously — format, lint, docstrings, types, tests, and coverage. Use when the user asks to "fix CI", "make CI green", or resolve pipeline failures. +allowed-tools: Read Write Edit Grep Glob Bash(just *) Bash(uv *) Bash(git diff:*) Bash(git status:*) +disable-model-invocation: true +context: fork +--- + Diagnose and fix all CI failures in this project. You are an autonomous CI-fixing agent. ## Step 1 — Run CI and capture output diff --git a/.claude/commands/ci.md b/.claude/commands/ci.md index c33d4c8..4586e95 100644 --- a/.claude/commands/ci.md +++ b/.claude/commands/ci.md @@ -1,21 +1,19 @@ -Run the full local CI pipeline for this Copier template repository and report results. +--- +description: Run the full local CI pipeline (fix, fmt, lint, type, test) and report results. Use when the user asks to "run CI", "check everything", or verify the project before committing. +allowed-tools: Bash(just *) +--- + +Run the full local CI pipeline for this repository and report results. Execute `just ci` which runs in this order: 1. `just fix` — auto-fix ruff lint issues 2. `just fmt` — ruff formatting -3. `just check` — read-only mirror of GitHub Actions, which runs: - - `uv sync --frozen --extra dev` - - `just fmt-check` — verify formatting (read-only) - - `ruff check .` — lint - - `basedpyright` — type check - - `just docs-check` — docstring coverage (`--select D`) - - `just test-ci` — pytest with coverage XML output - - `pre-commit run --all-files --verbose` - - `just audit` — pip-audit dependency security scan +3. `just lint` — ruff lint check +4. `just type` — basedpyright type check +5. `just test` — pytest After running, summarize: - Which steps passed and which failed - Any lint errors or type errors with file + line number - Any failing test names and their assertion messages -- Any security vulnerabilities found by pip-audit - Suggested fixes for any failures diff --git a/.claude/commands/coverage.md b/.claude/commands/coverage.md index d5e6bbe..d0e322b 100644 --- a/.claude/commands/coverage.md +++ b/.claude/commands/coverage.md @@ -1,43 +1,50 @@ +--- +description: Analyse test coverage and write tests to fill any gaps below the 85% threshold. Use when the user asks to "check coverage", "improve coverage", or "find uncovered code". +allowed-tools: Read Write Edit Grep Glob Bash(just *) Bash(uv *) +disable-model-invocation: true +--- + Analyse test coverage and write tests for any gaps found. ## Steps 1. **Run coverage** — execute `just coverage` to get the full report with missing lines: ``` - uv run --active pytest --cov --cov-report=term-missing --cov-report=xml + uv run --active pytest tests/ --cov=my_library --cov-report=term-missing ``` 2. **Parse results** — from the output identify: - - Overall coverage percentage - - Every module below **80 %** (list module name + actual percentage + missing line ranges) + - Overall coverage percentage (target: ≥ **85 %** per `[tool.coverage.report] fail_under`) + - Every module below 85 % (list module name + actual percentage + missing line ranges) 3. **Gap analysis** — for each under-covered module: - - Open the source file + - Open the source file at `src/my_library/` - Read the lines flagged as uncovered - Identify what scenarios, branches, or edge cases those lines represent 4. **Write missing tests** — for each gap: - - Locate the appropriate test file in `tests/` + - Locate the appropriate test file under `tests/` - Write one or more test functions that exercise the uncovered lines - Follow the existing test style (arrange / act / assert, parametrize over similar cases) - Ensure new tests pass: run `just test` after writing them -5. **Re-run coverage** — run `just coverage` again and confirm overall coverage has improved. +5. **Re-run coverage** — run `just coverage` again and confirm overall coverage has improved + and no module is below the 85 % threshold. ## Report format ``` -## Coverage Report +## Coverage Report — my_library -Overall: X% (target: ≥ 80%) +Overall: X% (target: ≥ 85%) ### Modules below threshold | Module | Coverage | Missing lines | |---------------|----------|----------------------| -| foo.bar | 62% | 45-52, 78, 91-95 | +| my_library.core | 72% | 45-52, 78 | ### Tests written -- tests/foo/test_bar.py: added test_edge_case_x, test_branch_y +- tests/.../test_core.py: added test_edge_case_x, test_branch_y ### After fixes Overall: Y% diff --git a/.claude/commands/dependency-check.md b/.claude/commands/dependency-check.md index 551660c..02cba51 100644 --- a/.claude/commands/dependency-check.md +++ b/.claude/commands/dependency-check.md @@ -1,3 +1,8 @@ +--- +description: Validate that uv.lock is in sync with pyproject.toml and committed. Use when the user asks to "check dependencies", "verify the lockfile", or before releasing. +allowed-tools: Read Bash(test *) Bash(git ls-files:*) Bash(git diff:*) Bash(stat *) Bash(date *) Bash(uv sync:*) +--- + Validate that `uv.lock` is in sync with `pyproject.toml` and committed. This command ensures dependencies are reproducible and locked, catching silent drift that diff --git a/.claude/commands/docs-check.md b/.claude/commands/docs-check.md index 96dd8cf..7ce1a02 100644 --- a/.claude/commands/docs-check.md +++ b/.claude/commands/docs-check.md @@ -1,11 +1,17 @@ +--- +description: Audit and repair Google-style docstrings across all Python source files. Use when the user asks to "check docs", "fix docstrings", or "audit documentation". +allowed-tools: Read Write Edit Grep Glob Bash(uv run:*) +disable-model-invocation: true +--- + Audit and repair documentation across all Python source files. ## Steps -1. **Run ruff docstring check** — `uv run --active ruff check --select D .` +1. **Run ruff docstring check** — `uv run --active ruff check --select D src/ tests/ scripts/` Report every violation with file, line, and rule code. -2. **Deep symbol scan** — for every `.py` file (including `tests/` and `scripts/`; ruff `D` applies there too): +2. **Deep symbol scan** — for every `.py` file under `src/my_library/`, `tests/`, and `scripts/`: - Read the file - Identify all public symbols: module-level functions, classes, methods not prefixed with `_` - For each symbol check: @@ -16,7 +22,7 @@ Audit and repair documentation across all Python source files. - Names match the actual parameter names exactly d. **Returns section** — present when the return type is not `None` e. **Raises section** — present when the function raises documented exceptions - f. **Style** — Google convention (sections use `Args:`, `Returns:`, `Raises:` headers) + f. **Style** — Google convention (`Args:`, `Returns:`, `Raises:` section headers) 3. **Module docstrings** — verify each `.py` file has a module-level docstring that describes the module's purpose in one sentence. @@ -25,12 +31,12 @@ Audit and repair documentation across all Python source files. docstring. Base the content on the function's signature, body, and surrounding context. Do not invent descriptions — if the purpose is unclear, write a minimal stub with a `TODO`. -5. **Verify** — re-run `uv run --active ruff check --select D .` and confirm zero violations. +5. **Verify** — re-run `uv run --active ruff check --select D src/ tests/ scripts/` and confirm zero violations. ## Output format ``` -## Documentation Audit +## Documentation Audit — my_library ### Ruff violations: N found [list violations] diff --git a/.claude/commands/generate.md b/.claude/commands/generate.md deleted file mode 100644 index be82a4c..0000000 --- a/.claude/commands/generate.md +++ /dev/null @@ -1,11 +0,0 @@ -Generate a test project from this Copier template into a temporary directory and inspect it. - -Steps: -1. Run: `copier copy . /tmp/test-output --trust --defaults` -2. List the files and directories created under `/tmp/test-output` -3. Show the contents of the generated `pyproject.toml` -4. Show the contents of the generated `justfile` (if present) -5. Check whether the generated project has a valid Python package structure under `src/` -6. Clean up: `rm -rf /tmp/test-output` - -Report any errors that occurred during generation and what they mean in the context of the template source files. diff --git a/.claude/commands/release.md b/.claude/commands/release.md index cc32f0f..7fbba13 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -1,11 +1,18 @@ +--- +description: Orchestrate a new release — verify CI, bump version, tag, and push to origin. Use when the user asks to "release", "cut a release", "ship a version", or "tag and publish". +argument-hint: [patch|minor|major|X.Y.Z] +allowed-tools: Read Edit Bash(git *) Bash(just ci:*) Bash(uv version:*) Bash(grep *) +disable-model-invocation: true +--- + Orchestrate a new release: verify CI, bump version, tag, and push. -This command automates the release workflow for this Copier template repository. +This command automates the release workflow for My Library. ## Prerequisites - All changes must be committed (no dirty working tree) -- You must have push access to the origin remote +- You must have push access to origin (https://github.com/yourusername/my-library) - The main/master branch must be up to date with origin ## Steps @@ -20,64 +27,82 @@ This command automates the release workflow for this Copier template repository. ```bash just ci ``` - If any step fails (lint, type, test, pre-commit), fix the issue and re-run. - Do not proceed until `just ci` passes completely. + This runs: fix → fmt → lint → type → docs-check → test → pre-commit. + If any step fails, fix the issue and re-run. Do not proceed until all steps pass. 3. **Determine the version bump** Ask the user: "What type of bump? (patch/minor/major) or specify explicit version (X.Y.Z)?" - - `patch` — bug fixes, no new features (0.0.2 → 0.0.3) - - `minor` — new features, backwards compatible (0.0.2 → 0.1.0) - - `major` — breaking changes (0.0.2 → 1.0.0) - - Explicit version — e.g., "0.1.0-rc1" for pre-releases (use with caution) + - `patch` — bug fixes, no new features (0.1.0 → 0.1.1) + - `minor` — new features, backwards compatible (0.1.0 → 0.2.0) + - `major` — breaking changes (0.1.0 → 1.0.0) -4. **Bump the version** using the version bump script +4. **Bump the version** in `pyproject.toml` + ```bash + # Using sed or a text editor: + # Change the version line in [project] section from X.Y.Z to the new version + ``` + Or use a bump tool if one is configured: ```bash - NEW_VERSION=$(python scripts/bump_version.py --bump patch) - # OR - NEW_VERSION=$(python scripts/bump_version.py --new-version X.Y.Z) + # Option: uv version --version X.Y.Z (if your project uses uv version) ``` - Output the new version to the user. 5. **Verify the version was bumped correctly** ```bash - grep version pyproject.toml | head -1 + grep "^version" pyproject.toml + ``` + Confirm the version matches your intended bump. + +6. **Create a commit** for the version bump + ```bash + git add pyproject.toml + git commit -m "chore(release): bump version to X.Y.Z" ``` - Confirm the version line matches the new version. -6. **Create a git tag** with the new version +7. **Create a git tag** with the new version ```bash - git tag v${NEW_VERSION} + git tag vX.Y.Z ``` + Tags should follow PEP 440 with a `v` prefix (e.g., `v0.2.0`, `v1.0.0-rc1`). -7. **Push the tag** to origin (this triggers release.yml) +8. **Push the commit and tag** to origin ```bash - git push origin v${NEW_VERSION} + git push origin main # or master, depending on your default branch + git push origin vX.Y.Z ``` -8. **Monitor the release workflow** - - The tag push triggers `.github/workflows/release.yml` - - Wait for the workflow to complete (check GitHub Actions) - - Confirm that a GitHub Release was created with release notes +9. **Create a GitHub Release** (if not automated) + - Go to: https://github.com/yourusername/my-library/releases + - Click "Draft a new release" + - Select the tag you just pushed + - Add release notes (summary of changes, new features, breaking changes, etc.) + - Publish the release ## Output Report to the user: ``` -✓ Release v created successfully +✓ Release vX.Y.Z created successfully -Next steps: -- GitHub Release: https://github.com/[org]/python_project_template/releases/tag/v -- Monitor workflow: https://github.com/[org]/python_project_template/actions +Release page: https://github.com/yourusername/my-library/releases/tag/vX.Y.Z -To update existing generated projects to this new template version: - copier update --trust # in an existing generated-project directory +Next steps: +- Check that the tag pushed successfully +- Consider updating your CHANGELOG.md if not auto-generated +- Notify users of the new release ``` ## Rollback (if something goes wrong) -If you need to undo a release before the workflow completes: +If you need to undo a release before publishing: ```bash -git tag -d v # delete local tag -git push origin :v # delete remote tag +git reset --soft HEAD~1 # undo the version commit, keep changes staged +git tag -d vX.Y.Z # delete local tag # Fix the issue, then run release again ``` + +If already pushed to origin: +```bash +git tag -d vX.Y.Z # delete local tag +git push origin :vX.Y.Z # delete remote tag (colon syntax or git push --delete) +git push origin +:main # force-push main back (use with caution!) +``` diff --git a/.claude/commands/review.md b/.claude/commands/review.md index 67d824b..3d7ad14 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -1,3 +1,9 @@ +--- +description: Perform a thorough pre-merge code review of recently modified Python files — lint, types, docstrings, test coverage. Use when the user asks to "review", "code review", or "check my changes" before merging. +allowed-tools: Read Grep Glob Bash(just *) Bash(git diff:*) Bash(git log:*) +context: fork +--- + Perform a thorough pre-merge code review of all recently modified Python files. ## Steps @@ -8,7 +14,13 @@ Perform a thorough pre-merge code review of all recently modified Python files. - `just type` — basedpyright type check; report all errors with file + line - `just docs-check` — ruff `--select D` docstring check -2. **Manual symbol scan** — for every `.py` file that was added or modified (use `git diff --name-only`): + For detailed guidance on each tool, load the relevant skill: + - Linting: `.claude/skills/linting/SKILL.md` + - Type checking: `.claude/skills/type-checking/SKILL.md` + - Docstrings: `.claude/skills/python-docstrings/SKILL.md` + +2. **Manual symbol scan** — for every `.py` file under `src/my_library/` that was + added or modified (use `git diff --name-only`): - Read the file - List every public function, class, and method (names not prefixed with `_`) - For each public symbol verify: @@ -18,15 +30,10 @@ Perform a thorough pre-merge code review of all recently modified Python files. - No bare `type: ignore` without an explanatory inline comment 3. **Test coverage sanity** — for each new function or class added, check that a corresponding - test exists in `tests/`. If any new public symbol lacks a test, list it explicitly. - -4. **Copier template integrity** (if any `.jinja` files changed) — verify that the Jinja2 hook - did not surface syntax errors. If it did, list the affected templates. + test exists under `tests/`. If any new public symbol lacks a test, list it explicitly. ## Output format -Produce a concise report: - ``` ## Code Review Report diff --git a/.claude/commands/standards.md b/.claude/commands/standards.md index ff93318..9ecd5c7 100644 --- a/.claude/commands/standards.md +++ b/.claude/commands/standards.md @@ -1,3 +1,8 @@ +--- +description: Run the complete standards enforcement suite (lint, types, docstrings, coverage) and produce a consolidated "ready to merge" report. Use when the user asks "am I ready to merge?", "check standards", or "run all checks". +allowed-tools: Read Grep Glob Bash(just *) Bash(git diff:*) +--- + Run the complete standards enforcement suite and produce a consolidated report. This is the "am I ready to merge?" command. It runs all checks and aggregates results. @@ -5,21 +10,18 @@ This is the "am I ready to merge?" command. It runs all checks and aggregates re ## Checks to run (execute concurrently where possible) 1. **Static analysis** — `just lint` + `just type` - - ruff: all configured rules (E, F, I, UP, B, SIM, C4, RUF, D, C90, PERF) + - ruff: all configured rules (E, F, I, UP, B, SIM, C4, RUF, D, TCH, PGH, PT, ARG, C90, PERF) - basedpyright: `standard` mode type checking 2. **Docstring coverage** — `just docs-check` - - All public symbols have Google-style docstrings + - All public symbols in `src/my_library/` have Google-style docstrings - All modules have module-level docstrings 3. **Test coverage** — `just coverage` - - Report overall percentage and flag any module below 80 % + - Target: ≥ 85 % (enforced by `[tool.coverage.report] fail_under = 85`) + - Report any module below threshold with its percentage -4. **Copier template integrity** (if any `.jinja` files are present) - - Confirm no unresolved Copier conflict markers (`<<<<<<`, `>>>>>>`) - - Confirm no stray `.rej` sidecar files - -5. **Definition-of-done checklist** — for every function or class added/modified: +4. **Definition-of-done checklist** — for every function or class added/modified: - [ ] Code passes lint + type check - [ ] Google-style docstring present - [ ] All parameters and return type annotated @@ -29,7 +31,7 @@ This is the "am I ready to merge?" command. It runs all checks and aggregates re ## Output format ``` -## Standards Report — +## Standards Report — My Library — ### ✓/✗ Static Analysis (ruff + basedpyright) [errors or "All clean"] @@ -38,12 +40,9 @@ This is the "am I ready to merge?" command. It runs all checks and aggregates re [violations or "All public symbols documented"] ### ✓/✗ Test Coverage -[modules below 80% or "All modules ≥ 80%"] +[modules below 85% or "All modules ≥ 85%"] Overall: X% -### ✓/✗ Template Integrity -[issues or "No conflicts or .rej files"] - ### Definition-of-Done Status [any unchecked items or "All items complete"] diff --git a/.claude/commands/tdd-green.md b/.claude/commands/tdd-green.md index fa7b0ca..0014a66 100644 --- a/.claude/commands/tdd-green.md +++ b/.claude/commands/tdd-green.md @@ -1,3 +1,8 @@ +--- +description: Validate the GREEN phase of a TDD cycle — confirm the target test now passes and no regressions were introduced. Use when the user asks to "check GREEN", "verify GREEN phase", or after implementing code to make a failing test pass. +allowed-tools: Read Bash(just test:*) Bash(uv run pytest:*) Bash(echo *) +--- + Validate the GREEN phase of a TDD cycle: confirm that the previously-failing test now PASSES and no regressions were introduced. Run the full test suite with `just test`. diff --git a/.claude/commands/tdd-red.md b/.claude/commands/tdd-red.md index 0b55517..81af293 100644 --- a/.claude/commands/tdd-red.md +++ b/.claude/commands/tdd-red.md @@ -1,3 +1,9 @@ +--- +description: Validate the RED phase of a TDD cycle — confirm a specific test fails for the right reason before implementation. Use when the user asks to "check RED", "verify RED phase", or after writing a new failing test. +argument-hint: [test-file-or-name] +allowed-tools: Read Bash(just test:*) Bash(uv run pytest:*) +--- + Validate the RED phase of a TDD cycle: confirm that a specific test FAILS for the right reason. Run the test suite with `just test` (or the specific test file if the user provides one). diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 939d40a..fdd7f0d 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -1,6 +1,11 @@ -Run the pytest test suite for this Copier template repository. +--- +description: Run the pytest test suite and report results with failure details. Use when the user asks to "run tests", "run the test suite", or "check if tests pass". +allowed-tools: Bash(just test:*) Bash(uv run pytest:*) +--- + +Run the pytest test suite for this project. Execute `just test` and then: - Report the total number of tests, how many passed, failed, or were skipped - For each failing test: show the test name, the file and line, and the full assertion error -- If all tests pass, confirm clearly and suggest whether any new template changes need corresponding test coverage +- If all tests pass, confirm clearly and note whether any recent code changes need new or updated tests diff --git a/.claude/commands/update-claude-md.md b/.claude/commands/update-claude-md.md index d924fb9..e7777a3 100644 --- a/.claude/commands/update-claude-md.md +++ b/.claude/commands/update-claude-md.md @@ -1,3 +1,9 @@ +--- +description: Detect and fix drift between CLAUDE.md and project config files (pyproject.toml, justfile, copier.yml). Use when the user asks to "sync CLAUDE.md", "update CLAUDE.md", or fix stale project documentation. +allowed-tools: Read Edit Grep Bash(date *) +disable-model-invocation: true +--- + Detect and fix drift between CLAUDE.md and the actual project configuration files. CLAUDE.md is the project's living standards contract. It must stay in sync with diff --git a/.claude/commands/validate-release.md b/.claude/commands/validate-release.md index 699fb60..f177e93 100644 --- a/.claude/commands/validate-release.md +++ b/.claude/commands/validate-release.md @@ -1,3 +1,10 @@ +--- +description: Simulate a release by rendering and testing the template against all feature combinations before shipping. Use when the user asks to "validate a release", "test the template", or before running /release. +allowed-tools: Read Bash(git status:*) Bash(just ci:*) Bash(copier *) Bash(grep *) Bash(rm -rf /tmp/test-*) +disable-model-invocation: true +context: fork +--- + Simulate a release by rendering and testing the template with all feature combinations. This command validates that a release won't break existing or new users by testing the template diff --git a/.claude/hooks/post-bash-pr-created.sh b/.claude/hooks/post-bash-pr-created.sh index daf3495..d9a87bf 100755 --- a/.claude/hooks/post-bash-pr-created.sh +++ b/.claude/hooks/post-bash-pr-created.sh @@ -14,13 +14,13 @@ set -euo pipefail INPUT=$(cat) -OUTPUT=$(python3 - <<'PYEOF' -import json, sys +OUTPUT=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_output", {}).get("output", "")) PYEOF -<<<"$INPUT") || exit 0 +) || exit 0 # Detect a GitHub PR URL in the command output PR_URL=$(echo "$OUTPUT" \ diff --git a/.claude/hooks/post-bash-test-coverage-reminder.sh b/.claude/hooks/post-bash-test-coverage-reminder.sh new file mode 100755 index 0000000..1d57a90 --- /dev/null +++ b/.claude/hooks/post-bash-test-coverage-reminder.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Claude PostToolUse hook — Bash +# After pytest / just test runs, parse coverage output and surface modules +# below the 85% project threshold. +# +# Only fires on commands that look like test or coverage runs (pytest, just +# test, just coverage, just ci). Reads the tool's stdout from tool_response and +# greps the coverage table; any src/ module under 85% is listed as a reminder +# so the developer can add tests before committing. +# +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 always (PostToolUse hooks cannot block) + +set -uo pipefail + +INPUT=$(cat) + +# Extract the bash command that was executed +TOOL_INPUT=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) +print(data.get("tool_input", {}).get("command", "")) +PYEOF +) || { echo "$INPUT"; exit 0; } + +# Only trigger on pytest / just test / just coverage / just ci commands +case "$TOOL_INPUT" in + *pytest*|*"just test"*|*"just coverage"*|*"just ci"*) ;; + *) echo "$INPUT"; exit 0 ;; +esac + +# Extract the tool's stdout from tool_response +TOOL_OUTPUT=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) +resp = data.get("tool_response") or data.get("tool_result") or {} +print(resp.get("stdout", "") or resp.get("output", "")) +PYEOF +) || { echo "$INPUT"; exit 0; } + +# Look for coverage output in the tool response +if echo "$TOOL_OUTPUT" | grep -q "TOTAL"; then + LOW_COVERAGE=$(CLAUDE_HOOK_INPUT="$TOOL_OUTPUT" python3 - <<'PYEOF' +import os +lines = os.environ["CLAUDE_HOOK_INPUT"].strip().split("\n") +low = [] +for line in lines: + parts = line.split() + if len(parts) >= 4 and parts[0].startswith("src/"): + try: + pct = int(parts[-1].rstrip("%")) + if pct < 85: + low.append(f"{parts[0]}: {pct}%") + except (ValueError, IndexError): + pass +if low: + print("\n".join(low)) +PYEOF +) + + if [[ -n "$LOW_COVERAGE" ]]; then + echo "┌─ Coverage reminder" + echo "│" + echo "│ Modules below 85% threshold:" + echo "$LOW_COVERAGE" | while IFS= read -r line; do + echo "│ ⚠ $line" + done + echo "│" + echo "└─ Consider writing tests to close gaps before committing" + fi +fi + +echo "$INPUT" +exit 0 diff --git a/.claude/hooks/post-edit-copier-migration.sh b/.claude/hooks/post-edit-copier-migration.sh index 48ab720..9d7d262 100755 --- a/.claude/hooks/post-edit-copier-migration.sh +++ b/.claude/hooks/post-edit-copier-migration.sh @@ -21,13 +21,13 @@ set -euo pipefail INPUT=$(cat) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") || exit 0 +) || exit 0 BASENAME=$(basename "$FILE_PATH") if [[ "$BASENAME" != "copier.yml" ]]; then diff --git a/.claude/hooks/post-edit-jinja.sh b/.claude/hooks/post-edit-jinja.sh index d1eeb41..995cc49 100755 --- a/.claude/hooks/post-edit-jinja.sh +++ b/.claude/hooks/post-edit-jinja.sh @@ -19,13 +19,13 @@ set -euo pipefail INPUT=$(cat) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") +) if [[ "$FILE_PATH" != *.jinja ]] || [[ -z "$FILE_PATH" ]] || [[ ! -f "$FILE_PATH" ]]; then exit 0 diff --git a/.claude/hooks/post-edit-markdown.sh b/.claude/hooks/post-edit-markdown.sh index dcfb124..bb52109 100755 --- a/.claude/hooks/post-edit-markdown.sh +++ b/.claude/hooks/post-edit-markdown.sh @@ -24,13 +24,13 @@ set -euo pipefail INPUT=$(cat) # Extract file_path from tool_input (works for both Edit and Write tools) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") +) # Only process .md files that exist if [[ "$FILE_PATH" != *.md ]] || [[ -z "$FILE_PATH" ]]; then diff --git a/.claude/hooks/post-edit-python.sh b/.claude/hooks/post-edit-python.sh index c09ec0e..159d72a 100755 --- a/.claude/hooks/post-edit-python.sh +++ b/.claude/hooks/post-edit-python.sh @@ -18,13 +18,13 @@ set -euo pipefail INPUT=$(cat) # Extract file_path from tool_input (works for both Edit and Write tools) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") +) # Only process .py files that actually exist if [[ "$FILE_PATH" != *.py ]] || [[ -z "$FILE_PATH" ]] || [[ ! -f "$FILE_PATH" ]]; then diff --git a/.claude/hooks/post-edit-refactor-test-guard.sh b/.claude/hooks/post-edit-refactor-test-guard.sh old mode 100644 new mode 100755 index 8bdbc2b..11c81e3 --- a/.claude/hooks/post-edit-refactor-test-guard.sh +++ b/.claude/hooks/post-edit-refactor-test-guard.sh @@ -7,16 +7,17 @@ # more than 3 source edits have occurred since the last test run, it surfaces a # reminder. This prevents silent regressions during the REFACTOR stage of TDD. # -# Exits: 0 always (PostToolUse hooks cannot block) +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 always (PostToolUse hooks cannot block) set -euo pipefail INPUT=$(cat) FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' -import json, sys +import json, os -data = json.load(sys.stdin) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) ') || { echo "$INPUT" diff --git a/.claude/hooks/post-edit-template-mirror.sh b/.claude/hooks/post-edit-template-mirror.sh index f7178d7..ee3fb0d 100755 --- a/.claude/hooks/post-edit-template-mirror.sh +++ b/.claude/hooks/post-edit-template-mirror.sh @@ -25,13 +25,13 @@ set -euo pipefail INPUT=$(cat) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") || exit 0 +) || exit 0 # Only fire for template/.claude/ edits if ! echo "$FILE_PATH" | grep -qE '(^|/)template/\.claude/'; then diff --git a/.claude/hooks/post-write-test-structure.sh b/.claude/hooks/post-write-test-structure.sh new file mode 100755 index 0000000..7c0bdd2 --- /dev/null +++ b/.claude/hooks/post-write-test-structure.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Claude PostToolUse hook — Write +# After creating test_*.py files, check for proper test structure: +# test_ functions, no unittest.TestCase, and module-level pytest markers. +# +# Only inspects files under tests/ or matching test_*.py. Surfaces warnings to +# Claude so it can self-correct in the same turn, but never blocks (PostToolUse +# cannot block). The three checks enforce: +# 1. At least one test_ function is defined in the file. +# 2. No unittest.TestCase classes (pytest function-based tests are preferred). +# 3. A pytestmark = pytest.mark. or @pytest.mark. is present. +# +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 always (PostToolUse hooks cannot block) + +set -uo pipefail + +INPUT=$(cat) + +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) +print(data.get("tool_input", {}).get("file_path", "")) +PYEOF +) || { echo "$INPUT"; exit 0; } + +# Only check test files that actually exist +case "$FILE_PATH" in + */test_*.py|*tests/*.py) ;; + *) echo "$INPUT"; exit 0 ;; +esac + +[[ -f "$FILE_PATH" ]] || { echo "$INPUT"; exit 0; } + +WARNINGS="" + +# Check for test_ functions +if ! grep -q "^def test_\|^ def test_\|^async def test_" "$FILE_PATH"; then + WARNINGS="${WARNINGS}\n│ ⚠ No test_ functions found — file may not contain any tests" +fi + +# Check for unittest.TestCase (discouraged) +if grep -q "unittest.TestCase\|class.*TestCase" "$FILE_PATH"; then + WARNINGS="${WARNINGS}\n│ ⚠ unittest.TestCase detected — prefer pytest function-based tests" +fi + +# Check for pytest markers +if ! grep -q "pytestmark\|@pytest.mark\." "$FILE_PATH"; then + WARNINGS="${WARNINGS}\n│ ⚠ No pytest markers — set pytestmark = pytest.mark. at module level" +fi + +if [[ -n "$WARNINGS" ]]; then + echo "┌─ Test structure check: $(basename "$FILE_PATH")" + echo -e "$WARNINGS" + echo "│" + echo "└─ Fix these issues to maintain test quality standards" +fi + +echo "$INPUT" +exit 0 diff --git a/.claude/hooks/pre-bash-block-no-verify.sh b/.claude/hooks/pre-bash-block-no-verify.sh index 459ec1c..c6b53b3 100755 --- a/.claude/hooks/pre-bash-block-no-verify.sh +++ b/.claude/hooks/pre-bash-block-no-verify.sh @@ -15,13 +15,13 @@ set -uo pipefail INPUT=$(cat) -COMMAND=$(python3 - <<'PYEOF' -import json, sys +COMMAND=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("command", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } if echo "$COMMAND" | grep -qE -- '--no-verify'; then echo "┌─ BLOCKED: --no-verify detected" >&2 diff --git a/.claude/hooks/pre-bash-branch-protection.sh b/.claude/hooks/pre-bash-branch-protection.sh new file mode 100755 index 0000000..b2cbede --- /dev/null +++ b/.claude/hooks/pre-bash-branch-protection.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Claude PreToolUse hook — Bash +# Block git push to main/master branches; feature branch pushes are allowed. +# +# Two guards are applied: +# 1. Explicit targets — `git push main|master` is blocked. +# 2. Implicit current-branch pushes — if HEAD is main/master and the command +# is a bare `git push`, `git push origin`, or `git push -u origin`, the +# push is blocked so the protected branch is never advanced directly. +# +# Developers should create a feature branch and open a pull request instead. +# +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 = allow | 2 = block + +set -uo pipefail + +INPUT=$(cat) + +COMMAND=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) +print(data.get("tool_input", {}).get("command", "")) +PYEOF +) || { echo "$INPUT"; exit 0; } + +# Only check git push commands +case "$COMMAND" in + *"git push"*) ;; + *) echo "$INPUT"; exit 0 ;; +esac + +# Explicit push to main/master +if echo "$COMMAND" | grep -qE "git push\s+\S+\s+(main|master)(\s|$)"; then + echo "┌─ BLOCKED: direct push to main/master" >&2 + echo "│" >&2 + echo "│ Pushing directly to a protected branch is not allowed." >&2 + echo "│ Use a feature branch and open a pull request instead." >&2 + echo "│" >&2 + echo "│ git checkout -b feat/my-feature" >&2 + echo "│ git push -u origin feat/my-feature" >&2 + echo "└─" >&2 + exit 2 +fi + +# Implicit push from main/master +CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || echo "") +if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then + if echo "$COMMAND" | grep -qE "^git push\s*$|^git push\s+origin\s*$|^git push\s+-u\s+origin\s*$"; then + echo "┌─ BLOCKED: HEAD is on protected branch '$CURRENT_BRANCH'" >&2 + echo "│" >&2 + echo "│ A bare 'git push' would advance the protected branch directly." >&2 + echo "│ Create a feature branch first:" >&2 + echo "│" >&2 + echo "│ git checkout -b feat/my-feature" >&2 + echo "│ git push -u origin feat/my-feature" >&2 + echo "└─" >&2 + exit 2 + fi +fi + +echo "$INPUT" diff --git a/.claude/hooks/pre-bash-commit-quality.sh b/.claude/hooks/pre-bash-commit-quality.sh index 1fc9183..8ef6ed5 100755 --- a/.claude/hooks/pre-bash-commit-quality.sh +++ b/.claude/hooks/pre-bash-commit-quality.sh @@ -22,13 +22,13 @@ set -uo pipefail INPUT=$(cat) -COMMAND=$(python3 - <<'PYEOF' -import json, sys +COMMAND=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("command", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } # Only fire for git commit commands if ! echo "$COMMAND" | grep -qE '^\s*git\s+commit\b'; then diff --git a/.claude/hooks/pre-bash-coverage-gate.sh b/.claude/hooks/pre-bash-coverage-gate.sh old mode 100644 new mode 100755 index 25be5f7..6b9bd56 --- a/.claude/hooks/pre-bash-coverage-gate.sh +++ b/.claude/hooks/pre-bash-coverage-gate.sh @@ -7,19 +7,20 @@ # hard gate — this hook gives earlier feedback so the developer can add missing # tests before committing. # -# Exits: 0 always (non-blocking; warnings on stderr only) +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 always (non-blocking; warnings on stderr only) set -uo pipefail INPUT=$(cat) -COMMAND=$(python3 - <<'PYEOF' -import json, sys +COMMAND=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("command", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } # Only fire for git commit commands. if ! echo "$COMMAND" | grep -qE '^\s*git\s+commit\b'; then diff --git a/.claude/hooks/pre-bash-git-push-reminder.sh b/.claude/hooks/pre-bash-git-push-reminder.sh index 4796fe1..7b3ce20 100755 --- a/.claude/hooks/pre-bash-git-push-reminder.sh +++ b/.claude/hooks/pre-bash-git-push-reminder.sh @@ -14,13 +14,13 @@ set -uo pipefail INPUT=$(cat) -COMMAND=$(python3 - <<'PYEOF' -import json, sys +COMMAND=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("command", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } if echo "$COMMAND" | grep -qE '^\s*git\s+push\b'; then echo "┌─ Reminder: git push must be done manually" >&2 diff --git a/.claude/hooks/pre-config-protection.sh b/.claude/hooks/pre-config-protection.sh index 3e5d0bc..b223106 100755 --- a/.claude/hooks/pre-config-protection.sh +++ b/.claude/hooks/pre-config-protection.sh @@ -25,13 +25,13 @@ set -uo pipefail INPUT=$(cat) # Extract file path first (used in error message for the BLOCK case) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } BASENAME=$(basename "$FILE_PATH") @@ -42,10 +42,10 @@ case "$BASENAME" in esac # Run protection analysis; outputs: OK | SKIP | BLOCK: | WARN: -CHECK=$(python3 - <<'PYEOF' -import json, re, sys +CHECK=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os, re, sys -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) new_content = ( data.get("tool_input", {}).get("new_string", "") or data.get("tool_input", {}).get("content", "") @@ -83,7 +83,7 @@ if basename in ("pyproject.toml", "pyproject.toml.jinja"): print("OK") PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } case "$CHECK" in BLOCK:*) diff --git a/.claude/hooks/pre-delete-protection.sh b/.claude/hooks/pre-delete-protection.sh new file mode 100755 index 0000000..6389da0 --- /dev/null +++ b/.claude/hooks/pre-delete-protection.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Claude PreToolUse hook — Bash +# Block `rm` (and recursive deletes) of files critical to the project +# infrastructure, and block `rm -rf` of the .claude/ directory. +# +# Protected files include pyproject.toml, justfile, CLAUDE.md, +# .pre-commit-config.yaml, .copier-answers.yml, uv.lock, and +# .claude/settings.json. These files are essential for builds, quality gates, +# and Claude Code configuration; deleting them through Claude is never the +# right move. If a real removal is needed, the user should do it manually. +# +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 = allow | 2 = block + +set -uo pipefail + +INPUT=$(cat) + +COMMAND=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) +print(data.get("tool_input", {}).get("command", "")) +PYEOF +) || { echo "$INPUT"; exit 0; } + +# Only check rm commands +case "$COMMAND" in + *rm*) ;; + *) echo "$INPUT"; exit 0 ;; +esac + +# Protected files — never delete these via Claude +PROTECTED_FILES=( + "pyproject.toml" + "justfile" + "CLAUDE.md" + ".pre-commit-config.yaml" + ".copier-answers.yml" + "uv.lock" + ".claude/settings.json" +) + +for protected in "${PROTECTED_FILES[@]}"; do + if echo "$COMMAND" | grep -q -- "$protected"; then + echo "┌─ BLOCKED: delete protection" >&2 + echo "│" >&2 + echo "│ Cannot delete critical file: $protected" >&2 + echo "│ These files are essential to the project infrastructure." >&2 + echo "│" >&2 + echo "└─ If you must remove this file, do it manually outside Claude." >&2 + exit 2 + fi +done + +# Block rm -rf on the .claude/ directory itself +if echo "$COMMAND" | grep -qE "rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|)\.claude(/|$|\s)"; then + echo "┌─ BLOCKED: delete protection" >&2 + echo "│" >&2 + echo "│ Cannot recursively delete the .claude/ directory." >&2 + echo "│ It contains skills, hooks, and settings that drive Claude Code." >&2 + echo "│" >&2 + echo "└─ Delete specific files within .claude/ instead, one at a time." >&2 + exit 2 +fi + +echo "$INPUT" diff --git a/.claude/hooks/pre-protect-readonly-files.sh b/.claude/hooks/pre-protect-readonly-files.sh index c6ff85a..98e025f 100755 --- a/.claude/hooks/pre-protect-readonly-files.sh +++ b/.claude/hooks/pre-protect-readonly-files.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Claude PreToolUse hook — Write|Edit|MultiEdit -# Block direct edits to files listed in assets/protected_files.csv. +# Block direct edits to files listed in .claude/hooks/protected_files.csv. # -# CSV format (assets/protected_files.csv): +# CSV format (.claude/hooks/protected_files.csv): # Column 1 (filepath) — repo-relative path; hook matches on the basename # Column 4 (ai_can_modify) — "never" triggers the block # Column 12 (reason) — shown in the block message @@ -10,11 +10,14 @@ # Rows starting with # are comments and are skipped. # The header row (filepath,...) is also skipped. # -# To protect a new file: add a row to assets/protected_files.csv. +# To protect a new file: add a row to .claude/hooks/protected_files.csv. # To unprotect a file: remove its row from that CSV. # Never edit this hook script to manage the list. # # Exits: 0 = allow | 2 = block +# +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 = allow | 2 = block (protected file) set -uo pipefail @@ -22,14 +25,14 @@ INPUT=$(cat) # Parse file_path from the JSON payload; pipe INPUT so heredoc/stdin don't conflict FILE_PATH=$(printf '%s' "$INPUT" | python3 -c " -import json, sys -data = json.loads(sys.stdin.read()) +import json, os +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get('tool_input', {}).get('file_path', '')) ") || { printf '%s' "$INPUT"; exit 0; } BASENAME=$(basename "$FILE_PATH") REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -CSV="${REPO_ROOT}/assets/protected_files.csv" +CSV="${REPO_ROOT}/.claude/hooks/protected_files.csv" if [[ ! -f "$CSV" ]]; then # CSV missing — fail open so development is not blocked @@ -77,7 +80,7 @@ if [[ "$RESULT" == BLOCK:* ]]; then printf '│ and commit via the normal review process.\n' >&2 printf '│\n' >&2 printf '│ To change the protected-files list, edit (with human review):\n' >&2 - printf '│ assets/protected_files.csv\n' >&2 + printf '│ .claude/hooks/protected_files.csv\n' >&2 printf '└─\n' >&2 exit 2 fi diff --git a/.claude/hooks/pre-protect-uv-lock.sh b/.claude/hooks/pre-protect-uv-lock.sh index 5d49f64..fddd948 100755 --- a/.claude/hooks/pre-protect-uv-lock.sh +++ b/.claude/hooks/pre-protect-uv-lock.sh @@ -21,13 +21,13 @@ set -uo pipefail INPUT=$(cat) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } BASENAME=$(basename "$FILE_PATH") diff --git a/.claude/hooks/pre-write-doc-file-warning.sh b/.claude/hooks/pre-write-doc-file-warning.sh index d1d2d4d..997663b 100755 --- a/.claude/hooks/pre-write-doc-file-warning.sh +++ b/.claude/hooks/pre-write-doc-file-warning.sh @@ -22,13 +22,13 @@ set -uo pipefail INPUT=$(cat) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } # Only process .md files if [[ "$FILE_PATH" != *.md ]] || [[ -z "$FILE_PATH" ]]; then diff --git a/.claude/hooks/pre-write-jinja-syntax.sh b/.claude/hooks/pre-write-jinja-syntax.sh index d2b62aa..b3ade48 100755 --- a/.claude/hooks/pre-write-jinja-syntax.sh +++ b/.claude/hooks/pre-write-jinja-syntax.sh @@ -23,13 +23,13 @@ set -uo pipefail INPUT=$(cat) -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys +FILE_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("file_path", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } # Only process .jinja files if [[ "$FILE_PATH" != *.jinja ]] || [[ -z "$FILE_PATH" ]]; then @@ -38,13 +38,13 @@ if [[ "$FILE_PATH" != *.jinja ]] || [[ -z "$FILE_PATH" ]]; then fi # Extract the file content from tool_input.content -CONTENT=$(python3 - <<'PYEOF' -import json, sys +CONTENT=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os -data = json.loads(sys.stdin.read()) +data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("tool_input", {}).get("content", "")) PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } if [[ -z "$CONTENT" ]]; then echo "$INPUT" @@ -52,10 +52,10 @@ if [[ -z "$CONTENT" ]]; then fi # Validate the Jinja2 syntax with the same extensions Copier uses -RESULT=$(python3 - <<'PYEOF' -import sys +RESULT=$(JINJA_CONTENT="$CONTENT" python3 - <<'PYEOF' +import os, sys -content = sys.argv[1] if len(sys.argv) > 1 else "" +content = os.environ.get("JINJA_CONTENT", "") try: from jinja2 import Environment, TemplateSyntaxError @@ -76,7 +76,7 @@ except TemplateSyntaxError as exc: except ImportError as exc: print(f"SKIP:Jinja2 not importable: {exc}") PYEOF -"$CONTENT") || { echo "$INPUT"; exit 0; } +) || { echo "$INPUT"; exit 0; } case "$RESULT" in OK) diff --git a/.claude/hooks/pre-write-src-require-test.sh b/.claude/hooks/pre-write-src-require-test.sh index 3c3531d..29d5ade 100755 --- a/.claude/hooks/pre-write-src-require-test.sh +++ b/.claude/hooks/pre-write-src-require-test.sh @@ -10,7 +10,8 @@ # between src/ and the file), excluding __init__.py. Nested layouts such as # src//common/foo.py are skipped. # -# Exits: 0 = allow | 2 = block (test file missing) +# Reference : Custom — project-specific hook, not derived from ECC. +# Exits : 0 = allow | 2 = block (test file missing) set -uo pipefail @@ -19,7 +20,7 @@ INPUT=$(cat) FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' import json, sys -data = json.load(sys.stdin) +data = json.loads(sys.stdin.read()) print(data.get("tool_input", {}).get("file_path", "")) ') || { echo "$INPUT" diff --git a/.claude/hooks/pre-write-src-test-reminder.sh b/.claude/hooks/pre-write-src-test-reminder.sh index 81a044a..43f8f37 100755 --- a/.claude/hooks/pre-write-src-test-reminder.sh +++ b/.claude/hooks/pre-write-src-test-reminder.sh @@ -18,7 +18,7 @@ INPUT=$(cat) FILE_PATH=$(printf '%s' "$INPUT" | python3 -c ' import json, sys -data = json.load(sys.stdin) +data = json.loads(sys.stdin.read()) print(data.get("tool_input", {}).get("file_path", "")) ') || { echo "$INPUT" diff --git a/assets/protected_files.csv b/.claude/hooks/protected_files.csv similarity index 100% rename from assets/protected_files.csv rename to .claude/hooks/protected_files.csv diff --git a/.claude/hooks/stop-cost-tracker.sh b/.claude/hooks/stop-cost-tracker.sh index c798435..19e9737 100755 --- a/.claude/hooks/stop-cost-tracker.sh +++ b/.claude/hooks/stop-cost-tracker.sh @@ -25,7 +25,7 @@ import json, os, sys, datetime from pathlib import Path try: - data = json.loads(sys.stdin.read()) + data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) except (json.JSONDecodeError, ValueError): sys.exit(0) diff --git a/.claude/hooks/stop-desktop-notify.sh b/.claude/hooks/stop-desktop-notify.sh index 0378dd3..b6db8be 100755 --- a/.claude/hooks/stop-desktop-notify.sh +++ b/.claude/hooks/stop-desktop-notify.sh @@ -30,20 +30,20 @@ PROJECT=$(basename "$PWD") MESSAGE="Task complete in $PROJECT" # Try to extract the last assistant message from the transcript for a richer notification -TRANSCRIPT_PATH=$(python3 - <<'PYEOF' -import json, sys +TRANSCRIPT_PATH=$(CLAUDE_HOOK_INPUT="$INPUT" python3 - <<'PYEOF' +import json, os try: - data = json.loads(sys.stdin.read()) + data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) print(data.get("transcript_path", "")) except Exception: print("") PYEOF -<<<"$INPUT") || true +) || true if [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then LAST_MSG=$(python3 - "$TRANSCRIPT_PATH" <<'PYEOF' -import json, sys +import json, os from pathlib import Path transcript_file = sys.argv[1] diff --git a/.claude/hooks/stop-evaluate-session.sh b/.claude/hooks/stop-evaluate-session.sh index 1f75899..51df591 100755 --- a/.claude/hooks/stop-evaluate-session.sh +++ b/.claude/hooks/stop-evaluate-session.sh @@ -27,7 +27,7 @@ from pathlib import Path from collections import Counter try: - data = json.loads(sys.stdin.read()) + data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) except (json.JSONDecodeError, ValueError): sys.exit(0) diff --git a/.claude/hooks/stop-session-end.sh b/.claude/hooks/stop-session-end.sh index 470c183..e529c42 100755 --- a/.claude/hooks/stop-session-end.sh +++ b/.claude/hooks/stop-session-end.sh @@ -29,7 +29,7 @@ import json, os, sys, datetime, subprocess from pathlib import Path try: - data = json.loads(sys.stdin.read()) + data = json.loads(os.environ["CLAUDE_HOOK_INPUT"]) except (json.JSONDecodeError, ValueError): sys.exit(0) diff --git a/.claude/rules/README.md b/.claude/rules/README.md index 82728a2..26594f3 100644 --- a/.claude/rules/README.md +++ b/.claude/rules/README.md @@ -1,108 +1,116 @@ # AI Rules — Developer Guide This directory contains the rules that inform any AI assistant (Claude Code, Cursor, etc.) -working on this codebase. Rules are plain Markdown files — no tool-specific frontmatter or -format is required. Any AI that can read context from a directory will benefit from them. +working in this project. Rules are plain Markdown files — readable by any tool without +conversion or special configuration. -## Structure +The format, scoping, and organisation conventions below are the canonical reference; they +match the `maintain-rules` skill. When adding or editing rules, follow this document. + +## Philosophy — rules vs skills + +Rules and skills serve strict, non-overlapping roles: + +- **Rules** (this directory) are **short, always-on, non-negotiable**. They hold hard + constraints ("never do X", "always use Y"), project invariants, and guardrails. Target + **5–7 lines per file**. If it's longer, it belongs in a skill. +- **Skills** (`.claude/skills/`) are **rich and invoked when relevant**. They hold + patterns, examples, step-by-step how-tos, templates, and edge cases. + +Before writing a new rule, check whether a skill already covers the content. If yes, +delete or shrink the rule — do not duplicate. -Rules are organised into a **common** layer plus **language/tool-specific** directories: +## Structure ``` .claude/rules/ ├── README.md ← you are here -├── common/ # Universal principles — apply to all code in this repo +├── common/ # Universal hard constraints — apply to all code │ ├── coding-style.md │ ├── git-workflow.md │ ├── testing.md │ ├── security.md -│ ├── development-workflow.md -│ └── code-review.md -├── python/ # Python-specific (extends common/) -│ ├── coding-style.md -│ ├── testing.md -│ ├── patterns.md -│ ├── security.md │ └── hooks.md -├── jinja/ # Jinja2 template-specific -│ ├── coding-style.md -│ └── testing.md +├── python/ # Python-specific (extends common/) ├── bash/ # Shell script-specific -│ ├── coding-style.md -│ └── security.md +├── yaml/ # YAML authoring conventions ├── markdown/ # Markdown authoring conventions -│ └── conventions.md -├── yaml/ # YAML file conventions (copier.yml, workflows, etc.) -│ └── conventions.md -└── copier/ # Copier template-specific rules (this repo only) - └── template-conventions.md +└── copier/ # Copier template-repo conventions ``` +Detailed how-to content that previously lived here has moved to skills: + +| Former rule | Now lives in | +|---|---| +| `common/development-workflow.md` | `skills/sdlc-workflow/`, `skills/tdd-workflow/` | +| `common/code-review.md` | `skills/python-code-reviewer/` | +| `python/security.md` | `skills/security/` | +| `python/patterns.md` | `skills/python-code-quality/` | +| `jinja/coding-style.md`, `jinja/testing.md` | `skills/jinja-guide/` | + ## Rule priority -When language-specific rules and common rules conflict, **language-specific rules take -precedence** (specific overrides general). This mirrors CSS specificity and `.gitignore` -precedence. +Language-specific rules override common rules where they conflict. -- `common/` defines universal defaults. -- Language directories (`python/`, `jinja/`, `bash/`, …) override those defaults where - language idioms differ. +## Standard rule format -## Dual-hierarchy reminder +Every rule file follows one of two shapes. -This Copier meta-repo has **two parallel rule trees**: +### Unconditional rule — `common/*.md` -``` -.claude/rules/ ← active when DEVELOPING this template repo -template/.claude/rules/ ← rendered into every GENERATED project +Loads on every session. No frontmatter. + +```markdown +# [Topic Name] + +- Concrete, verifiable instruction. +- Another specific instruction. ``` -When you add or modify a rule: -- Changes to `template/.claude/rules/` affect every project generated from this - template going forward. -- Changes to the root `.claude/rules/` affect only this meta-repo. -- Many rules belong in **both** trees (e.g. Python coding style, security). -- Copier-specific rules (`copier/`) belong only in the root tree. -- Jinja rules belong only in the root tree (generated projects do not contain Jinja files). +### Path-scoped rule — language/topic directories -## How to write a new rule +Loads only when Claude touches files matching the globs. Uses YAML frontmatter with a +`paths:` key. -1. **Choose the right directory** — `common/` for language-agnostic principles, - a language directory for language-specific ones. Create a new directory if a - language or domain does not exist yet. +```markdown +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- -2. **File name** — use lowercase kebab-case matching the topic: `coding-style.md`, - `testing.md`, `patterns.md`, `security.md`, `hooks.md`, `performance.md`. +# [Topic Name] -3. **Opening line** — if the file extends a common counterpart, start with: - ``` - > This file extends [common/xxx.md](../common/xxx.md) with specific content. - ``` +- Rule 1 +- Rule 2 +``` -4. **Content guidelines**: - - State rules as actionable imperatives ("Always …", "Never …", "Prefer …"). - - Use concrete code examples (correct and incorrect) wherever possible. - - Keep each file under 150 lines; split into multiple files if a topic grows larger. - - Do not repeat content already covered in the common layer — cross-reference instead. - - Avoid tool-specific configuration syntax in rule prose; describe intent, not config. +Notes: +- `paths:` values are glob patterns, always double-quoted. +- Do **not** use the legacy `# applies-to:` comment syntax. Always use YAML frontmatter. -5. **File patterns annotation** (optional but helpful) — add a YAML comment block at the - top listing which glob patterns the rule applies to. AI tools that understand frontmatter - can use this; tools that do not will simply skip the comment: - ```yaml - # applies-to: **/*.py, **/*.pyi - ``` +## Authoring rules — checklist -6. **Mirror to `template/.claude/rules/`** if the rule is relevant to generated projects. +Before committing a rule, verify: -7. **Update this README** when adding a new language directory or a new top-level file. +1. **Location** — `common/` for language-agnostic, language directory otherwise. +2. **Filename** — lowercase kebab-case (`coding-style.md`, `testing.md`). +3. **Frontmatter** — present with `paths:` for path-scoped; absent for unconditional. +4. **Title** — single `#` heading matching the topic. +5. **Content** — hard constraints only; imperatives; concrete. +6. **Size** — **5–7 lines per file** (excluding frontmatter and title). Longer content goes in a skill. +7. **No duplication** — if a skill already covers it, remove the rule. +8. **README** — update this file when adding a new directory. ## How AI tools consume these rules | Tool | Mechanism | |------|-----------| -| Claude Code | Reads `CLAUDE.md` (project root), then any file you reference or load via slash commands | +| Claude Code | Loads `CLAUDE.md` plus every `.claude/rules/**/*.md`; path-scoped files activate when matching files are touched | | Cursor | Reads `.cursor/rules/*.mdc`; symlink or copy relevant rules there if desired | -| Generic LLM | Pass rule file contents in system prompt or context window | +| Generic LLM | Pass rule file contents in the system prompt or context window | + +## Related skill -Because rules are plain Markdown, they are readable by any tool without conversion. +The `maintain-rules` skill (at `.claude/skills/maintain-rules/`) contains the full +reference for authoring, auditing, and organising these files. diff --git a/.claude/rules/bash/coding-style.md b/.claude/rules/bash/coding-style.md index 843c848..889dc9c 100644 --- a/.claude/rules/bash/coding-style.md +++ b/.claude/rules/bash/coding-style.md @@ -1,135 +1,13 @@ -# Bash / Shell Coding Style - -# applies-to: **/*.sh - -Shell scripts in this repository are hook scripts under `.claude/hooks/` and helper -scripts under `scripts/`. These rules apply to all `.sh` files. - -## Shebang and strict mode - -Every script must start with the appropriate shebang and enable strict mode: - -```bash -#!/usr/bin/env bash -# One-line description of what this script does. -``` - -For PostToolUse / Stop / SessionStart hooks (non-blocking): - -```bash -set -euo pipefail -``` - -For PreToolUse blocking hooks (must not exit non-zero accidentally): - -```bash -set -uo pipefail # intentionally NOT -e; we handle exit codes manually -``` - -`-e` (exit on error), `-u` (error on unset variable), `-o pipefail` (pipe failures propagate). -Document clearly when `-e` is omitted and why. - -## Variable quoting - -Always quote variable expansions unless you intentionally want word splitting: - -```bash -# Correct -file_path="$1" -if [[ -f "$file_path" ]]; then ... - -# Wrong — breaks on paths with spaces -if [[ -f $file_path ]]; then ... -``` - -Use `[[ ]]` (bash conditionals) instead of `[ ]` (POSIX test). `[[ ]]` handles -spaces in variables without quoting issues. - -## Reading stdin (hook scripts) - -Hook scripts receive JSON on stdin. Always capture it immediately and parse with Python: - -```bash -INPUT=$(cat) - -FILE_PATH=$(python3 - <<'PYEOF' -import json, sys -data = json.loads(sys.stdin.read()) -print(data.get("tool_input", {}).get("file_path", "")) -PYEOF -<<<"$INPUT") || { echo "$INPUT"; exit 0; } -``` - -The `|| { echo "$INPUT"; exit 0; }` guard ensures that a malformed JSON payload never -accidentally blocks a PreToolUse hook. - -## Output formatting - -Use box-drawing characters for structured output (consistent with all other hooks): - -```bash -echo "┌─ Hook name: $context" -echo "│" -echo "│ Informational content" -echo "└─ ✓ Done" # or └─ ✗ Fix before committing -``` - -- PostToolUse / Stop / SessionStart hooks: print to **stdout**. -- PreToolUse blocking messages: print to **stderr** (shown to the user on block). - -## Exit codes - -| Script type | Exit 0 | Exit 2 | -|-------------|--------|--------| -| PreToolUse | Allow tool to proceed | Block tool call | -| PostToolUse | Normal completion | Not meaningful — avoid | -| Stop / SessionStart | Normal completion | Not meaningful — avoid | - -Only PreToolUse hooks should ever exit 2. All other hooks must exit 0. - -When a PreToolUse hook allows the call to proceed, echo `$INPUT` back to stdout (required): - -```bash -echo "$INPUT" # pass-through: required for the tool call to proceed -exit 0 -``` - -## Naming and file organisation - -File naming convention: -``` -{event-prefix}-{matcher}-{purpose}.sh -``` - -| Prefix | Lifecycle event | -|--------|----------------| -| `pre-bash-` | PreToolUse on Bash | -| `pre-write-` | PreToolUse on Write | -| `pre-config-` | PreToolUse on Edit/Write/MultiEdit | -| `pre-protect-` | PreToolUse guard for a specific resource | -| `post-edit-` | PostToolUse on Edit or Write | -| `post-bash-` | PostToolUse on Bash | -| `session-` | SessionStart | -| `stop-` | Stop | - -## Functions - -Extract repeated logic into shell functions. Name functions in `snake_case`: - -```bash -check_python_file() { - local file_path="$1" - [[ "$file_path" == *.py ]] && [[ -f "$file_path" ]] -} -``` - -Keep functions short (≤ 30 lines). Scripts that grow beyond ~100 lines should be -split into multiple files or rewritten as Python. - -## Portability - -Hook scripts run on macOS and Linux. Avoid GNU-specific flags when a POSIX-compatible -alternative exists. Test both platforms if in doubt. - -Copier `_tasks` use `/bin/sh` (POSIX shell, not bash). Use `#!/bin/sh` and POSIX-only -syntax in tasks embedded in `copier.yml`. +--- +paths: + - "**/*.sh" + - "**/*.bash" +--- + +# Bash Coding Style + +- Start with `#!/usr/bin/env bash` and `set -euo pipefail` (omit `-e` for PreToolUse hooks). +- Always quote variable expansions: `"$var"`. Use `[[ ]]`, not `[ ]`. +- PreToolUse hooks: echo `$INPUT` and exit 0 to allow; exit 2 to block. +- Print to stdout for PostToolUse/Stop output; print to stderr for PreToolUse blocking messages. +- Hooks run on macOS and Linux — avoid GNU-specific flags. diff --git a/.claude/rules/bash/security.md b/.claude/rules/bash/security.md index 7bfee6d..e4f5051 100644 --- a/.claude/rules/bash/security.md +++ b/.claude/rules/bash/security.md @@ -1,92 +1,13 @@ -# Bash Security - -# applies-to: **/*.sh - -> This file extends [common/security.md](../common/security.md) with Bash-specific content. - -## Never use `eval` - -`eval` executes arbitrary strings as code. Any user-controlled input passed to `eval` -is a code injection vulnerability: - -```bash -# Wrong — if $user_input = "rm -rf /", this destroys the filesystem -eval "process_$user_input" - -# Correct — use a case statement or associative array -case "$action" in - start) start_service ;; - stop) stop_service ;; - *) echo "Unknown action: $action" >&2; exit 1 ;; -esac -``` - -## Never use `shell=True` equivalent - -Constructing commands via string interpolation and passing to a shell interpreter -enables injection: - -```bash -# Wrong — $filename could contain shell metacharacters -system("process $filename") - -# Correct — pass as separate argument -process_file "$filename" -``` - -When calling external programs, pass arguments as separate words, never concatenated -into a single string. - -## Validate and sanitise all inputs +--- +paths: + - "**/*.sh" + - "**/*.bash" +--- -Scripts that accept arguments or read from environment variables must validate them -before use: - -```bash -file_path="${1:?Usage: script.sh }" # fail with message if empty - -# Reject paths containing traversal sequences -if [[ "$file_path" == *..* ]]; then - echo "Error: path traversal not allowed" >&2 - exit 1 -fi -``` - -## Secrets in environment variables - -- Do not echo or log environment variables that may contain secrets. -- Do not write secrets to temporary files unless the file is created with `mktemp` - and cleaned up in an `EXIT` trap. -- Check that required environment variables exist before using them: - -```bash -: "${API_KEY:?API_KEY environment variable is required}" -``` - -## Temporary file handling - -Use `mktemp` for temporary files and clean up with a trap: - -```bash -TMPFILE=$(mktemp) -trap 'rm -f "$TMPFILE"' EXIT - -# Use $TMPFILE safely -some_command > "$TMPFILE" -process_output "$TMPFILE" -``` - -Never use predictable filenames like `/tmp/output.txt` — they are vulnerable to -symlink attacks. - -## Subprocess calls in hook scripts - -Hook scripts in `.claude/hooks/` execute in the context of the developer's machine. -They should: -- Only call trusted binaries (`uv`, `git`, `python3`, `ruff`, `basedpyright`). -- Never download or execute code from the network. -- Avoid `curl | bash` patterns. -- Not modify files outside the project directory. +# Bash Security -The `pre-bash-block-no-verify.sh` hook blocks `git commit --no-verify` to ensure -pre-commit security gates cannot be bypassed. +- Never use `eval`; use `case` statements for dispatch. +- Pass arguments as separate words — never concatenate user input into a shell string. +- Validate all arguments and env vars before use; reject paths containing `..`. +- Use `mktemp` for temp files with `trap 'rm -f "$TMPFILE"' EXIT`. +- Hook scripts must only call trusted binaries (`uv`, `git`, `python3`, `ruff`); never `curl | bash`. diff --git a/.claude/rules/common/code-review.md b/.claude/rules/common/code-review.md deleted file mode 100644 index b0f2560..0000000 --- a/.claude/rules/common/code-review.md +++ /dev/null @@ -1,77 +0,0 @@ -# Code Review Standards - -## When to review - -Review your own code before every commit, and request a peer review before merging to -`main`. Self-review catches the majority of issues before CI runs. - -**Mandatory review triggers:** -- Any change to security-sensitive code: authentication, authorisation, secret handling, - user input processing. -- Architectural changes: new modules, changed public APIs, modified data models. -- Changes to CI/CD configuration or deployment scripts. -- Changes to `copier.yml` that affect generated projects. - -## Self-review checklist - -Before opening a PR, verify each item: - -**Correctness** -- [ ] Code does what the requirement says. -- [ ] Edge cases are handled (empty inputs, None/null, boundary values). -- [ ] Error paths are covered by tests. - -**Code quality** -- [ ] Functions are focused; no function exceeds 80 lines. -- [ ] No deep nesting (> 4 levels); use early returns instead. -- [ ] Variable and function names are clear and consistent with the codebase. -- [ ] No commented-out code left in. - -**Testing** -- [ ] Every new public symbol has at least one test. -- [ ] `just coverage` shows no module below threshold. -- [ ] Tests are isolated and do not depend on order. - -**Documentation** -- [ ] Every new public function/class/method has a Google-style docstring. -- [ ] `CLAUDE.md` is updated if project structure or commands changed. - -**Security** -- [ ] No hardcoded secrets. -- [ ] No new external dependencies without justification. -- [ ] User inputs are validated. - -**CI** -- [ ] `just ci` passes with zero errors locally before pushing. - -## Severity levels - -| Level | Meaning | Required action | -|-------|---------|-----------------| -| CRITICAL | Security vulnerability or data-loss risk | Block merge — must fix | -| HIGH | Bug or significant quality issue | Should fix before merge | -| MEDIUM | Maintainability or readability concern | Consider fixing | -| LOW | Style suggestion or minor improvement | Optional | - -## Common issues to catch - -| Issue | Example | Fix | -|-------|---------|-----| -| Large functions | > 80 lines | Extract helpers | -| Deep nesting | `if a: if b: if c:` | Early returns | -| Missing error handling | Bare `except:` | Handle specifically | -| Hardcoded magic values | `if status == 3:` | Named constant | -| Missing type annotations | `def foo(x):` | Add type hints | -| Missing docstring | No docstring on public function | Add Google-style docstring | -| Debug artefacts | `print("here")` | Remove or use logger | - -## Integration with automated checks - -The review checklist is enforced at multiple layers: - -- **PostToolUse hooks**: ruff + basedpyright fire after every `.py` edit in a Claude session. -- **Pre-commit hooks**: ruff, basedpyright, secret scan on every `git commit`. -- **CI**: full `just ci` run on every push and pull request. - -Fix violations at the earliest layer — it is cheaper to fix a ruff error immediately -after editing a file than to fix it after the CI pipeline fails. diff --git a/.claude/rules/common/coding-style.md b/.claude/rules/common/coding-style.md index a6ed19a..b584edb 100644 --- a/.claude/rules/common/coding-style.md +++ b/.claude/rules/common/coding-style.md @@ -1,64 +1,8 @@ -# Coding Style — Common Principles - -These principles apply to all languages in this repository. Language-specific directories -extend or override individual rules where language idioms differ. - -## Naming - -- Use descriptive, intention-revealing names. Abbreviations are acceptable only when - universally understood in the domain (e.g. `url`, `id`, `ctx`). -- Functions and variables: `snake_case` for Python/Bash, follow language convention otherwise. -- Constants: `UPPER_SNAKE_CASE`. -- Avoid single-letter names except for short loop counters and mathematical variables. - -## Function size and focus - -- Functions should do one thing. If you need "and" to describe what a function does, - split it. -- Target ≤ 40 lines per function body; hard limit 80 lines. Exceeding this is a signal - to extract helpers. -- McCabe cyclomatic complexity ≤ 10 (enforced by ruff `C90`). - -## File size and cohesion - -- Keep files cohesive — one module, one concern. -- Target ≤ 400 lines per file; treat 600 lines as a trigger to extract a submodule. - -## Immutability preference - -- Prefer immutable data structures and values. Mutate in place only when necessary for - correctness or performance. -- **Language note**: overridden in languages where mutation is idiomatic (e.g. Go pointer - receivers). - -## Error handling - -- Never silently swallow exceptions or errors. Either handle them explicitly or propagate. -- Log errors with sufficient context to diagnose the issue without a debugger. -- Do not use bare `except:` / catch-all blocks unless re-raising immediately. - -## No magic values - -- Replace bare literals (`0`, `""`, `"pending"`) with named constants or enums. -- Document the origin of non-obvious numeric thresholds in a comment. - -## No debug artefacts - -- Remove `print()`, `console.log()`, `debugger`, and temporary debug variables before - committing. Use the project's logging infrastructure instead. - -## Comments - -- Comments explain *why*, not *what*. Code should be self-documenting. -- Avoid comments that merely restate what the code does ("increment counter", "return result"). -- Use `TODO(username): description` for tracked work items; never leave bare `TODO` or - `FIXME` in committed code. - -## Line length - -- 100 characters (enforced by ruff formatter for Python). Wrap long expressions clearly. - -## Imports - -- Group and sort imports: standard library → third-party → local. One blank line between groups. -- Absolute imports preferred over relative imports except within the same package. +# Coding Style + +- Line length: 100 characters (enforced by ruff formatter). +- Functions: one thing only; target ≤ 40 lines, hard limit 80 lines; McCabe complexity ≤ 10. +- Never silently swallow exceptions; no bare `except:` unless re-raising immediately. +- No magic values — replace bare literals with named constants or enums. +- No `print()` or debug variables in committed code; use structured logging. +- Comments explain *why*, not *what*. Use `TODO(username): description` for tracked work. diff --git a/.claude/rules/common/development-workflow.md b/.claude/rules/common/development-workflow.md deleted file mode 100644 index 76e572a..0000000 --- a/.claude/rules/common/development-workflow.md +++ /dev/null @@ -1,85 +0,0 @@ -# Development Workflow - -> This file describes the full feature development pipeline. Git operations are -> covered in [git-workflow.md](./git-workflow.md). - -## Before writing any code: research first - -1. **Search the existing codebase** for similar patterns before adding new ones. - Prefer consistency with existing code over novelty. -2. **Check third-party libraries** (PyPI, crates.io, etc.) before writing utility code. - Prefer battle-tested libraries over hand-rolled solutions for non-trivial problems. -3. **Read the relevant rule files** in `.claude/rules/` for the language or domain - you are working in. - -## Feature implementation workflow - -### 1. Understand the requirement - -- Read the issue, PR description, or task thoroughly before touching code. -- Identify edge cases and failure modes upfront. Write them down as test cases. -- For large changes, sketch the data flow and affected modules before coding. - -### 2. Write tests first (TDD) - -See [testing.md](./testing.md) for the full TDD workflow. Summary: - -- Write one failing test per requirement or edge case. -- Run `just test` to confirm the test fails for the right reason. -- Then write the implementation. - -### 3. Implement - -- Follow the coding style rules for the relevant language. -- Keep the diff small and focused. One PR per logical change. -- Run the language-specific PostToolUse hook output (ruff, basedpyright) after every - file edit and fix violations before moving on. - -### 4. Self-review before opening a PR - -Run each check individually before the full suite so failures are isolated: - -```bash -just fix # auto-fix ruff violations -just fmt # format with ruff formatter -just lint # ruff lint — must be clean -just type # basedpyright — must be clean -just docs-check # docstring completeness — must be clean -just test # all tests — must pass -just coverage # no module below coverage threshold -``` - -Or run the full pipeline: - -```bash -just ci # fix → fmt → lint → type → docs-check → test → precommit -``` - -### 5. Commit and open a PR - -See [git-workflow.md](./git-workflow.md) for commit message format and PR conventions. - -## Working in this repo vs generated projects - -| Context | You are working on… | Use `just` from… | -|---------|---------------------|-----------------| -| Root repo | The Copier template itself | `/workspace/` | -| Generated project | A project produced by `copier copy` | The generated project root | - -Changes to `template/` affect all future generated projects. Test template changes by -running `copier copy . /tmp/test-output --trust --defaults` and inspecting the output. -Clean up with `rm -rf /tmp/test-output`. - -## Toolchain quick reference - -| Task | Command | -|------|---------| -| Run tests | `just test` | -| Coverage report | `just coverage` | -| Lint | `just lint` | -| Format | `just fmt` | -| Type check | `just type` | -| Docstring check | `just docs-check` | -| Full CI | `just ci` | -| Diagnose environment | `just doctor` | -| Generate test project | `copier copy . /tmp/test-output --trust --defaults` | diff --git a/.claude/rules/common/git-workflow.md b/.claude/rules/common/git-workflow.md index 3218b3f..2c03b20 100644 --- a/.claude/rules/common/git-workflow.md +++ b/.claude/rules/common/git-workflow.md @@ -1,84 +1,6 @@ # Git Workflow -## Commit message format - -``` -: - - - - -``` - -**Types**: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build` - -Rules: -- Subject line ≤ 72 characters; use imperative mood ("Add feature", not "Added feature"). -- Body wraps at 80 characters. -- Reference issues in the footer (`Closes #123`), not the subject. -- One logical change per commit. Do not bundle unrelated fixes. - -## Branch naming - -``` -/ # e.g. feat/add-logging-manager -/-description # e.g. fix/42-null-pointer -``` - -## What never goes in a commit - -- Hardcoded secrets, API keys, tokens, or passwords. -- Generated artefacts that are reproducible from source (build output, `*.pyc`, `.venv/`). -- Merge-conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). -- `*.rej` files left by Copier update conflicts. -- Debug statements (`print()`, `debugger`, `pdb.set_trace()`). - -The `pre-bash-commit-quality.sh` hook scans staged files for the above before every commit. - -## Protected operations - -These commands are **blocked** by pre-commit hooks and must not be run without explicit -justification: -- `git commit --no-verify` — bypasses quality gates. -- `git push --force` — rewrites shared history. -- `git push` directly to `main` — use pull requests. - -**Maintainers:** enforce PR-only `main` and squash merges in GitHub **Settings** / branch -protection; see `docs/github-repository-settings.md` in this repository (single checklist). - -## TDD commit conventions - -When committing TDD work, structure commits to reflect the discipline: - -- Use `test:` type for RED commits (failing test added). -- Use `feat:` or `fix:` type for GREEN commits (implementation that makes tests pass). -- Use `refactor:` type for REFACTOR commits (no behaviour change). -- Include test context in the commit body: which scenarios are covered, what edge - cases were tested, and why the implementation approach was chosen. - -Example: -``` -feat: add discount calculation for premium users - -Implements tiered discount logic. TDD cycle covered: -- Happy path: 10% discount for premium tier -- Edge cases: zero-item cart, negative prices rejected -- Boundary: exactly-at-threshold cart values - -Tests: test_calculate_discount_* in tests/test_pricing.py -``` - -## Pull request workflow - -1. Run `just review` (lint + types + docstrings + tests) before opening a PR. -2. Use `git diff main...HEAD` to review all changes since branching. -3. Write a PR description that explains the *why* behind the change, not just the *what*. -4. Include a test plan: which scenarios were verified manually or with automated tests. -5. All CI checks must be green before requesting review. -6. Squash-merge feature branches; preserve merge commits for release branches. - -## Copier template repos — additional rules - -- Never edit `.copier-answers.yml` by hand — the update algorithm depends on it. -- Resolve `*.rej` conflict files before committing; they indicate unreviewed merge conflicts. -- Tag releases with PEP 440-compatible versions (`1.2.3`, `1.2.3a1`) for `copier update` to work. +- Commit format: `: ` (subject ≤ 72 chars). Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build`. +- Never commit: hardcoded secrets, `*.rej` files, merge-conflict markers, debug statements. +- Blocked operations: `git commit --no-verify`, `git push --force`, direct push to `main`. +- Run `just review` before opening a PR; all CI checks must be green. diff --git a/.claude/rules/common/hooks.md b/.claude/rules/common/hooks.md index 8199545..282b335 100644 --- a/.claude/rules/common/hooks.md +++ b/.claude/rules/common/hooks.md @@ -1,11 +1,6 @@ -# Hooks (Common) - -This document defines shared conventions for Claude Code hooks across languages. -Language-specific hook requirements live in their respective rule files (e.g. `python/hooks.md`). - -## General rules +# Hooks - Hooks must be fast and side-effect free unless explicitly intended. -- **PreToolUse** hooks may block actions (exit code `2`) and should avoid long-running commands. -- **PostToolUse** hooks must not block (exit code is ignored); use them for reminders and checks. -- Prefer printing concise, actionable guidance on stderr for warnings and blocks. +- PreToolUse hooks may block (exit code `2`); echo `$INPUT` back to stdout to allow. +- PostToolUse and Stop hooks must exit `0`; use them for reminders only, not blocking. +- Document new hooks in `hooks/README.md` with lifecycle event, matcher, and blocking behaviour. diff --git a/.claude/rules/common/security.md b/.claude/rules/common/security.md index 2c3e043..e17a9d2 100644 --- a/.claude/rules/common/security.md +++ b/.claude/rules/common/security.md @@ -1,63 +1,6 @@ -# Security Guidelines +# Security -## Pre-commit security checklist - -Before every commit, verify: - -- [ ] No hardcoded secrets: API keys, passwords, tokens, private keys. -- [ ] No credentials in comments or docstrings. -- [ ] All user-supplied inputs are validated before use. -- [ ] Parameterised queries used for all database operations (no string-formatted SQL). -- [ ] File paths from user input are sanitised (no path traversal). -- [ ] Error messages do not expose stack traces, internal paths, or configuration values - to end users. -- [ ] New dependencies are from trusted sources and pinned to specific versions. - -The `pre-bash-commit-quality.sh` hook performs a basic automated scan, but it does not -replace manual review. - -## Secret management - -- **Never** hardcode secrets in source files, configuration, or documentation. -- Use environment variables; load them with a library (e.g. `python-dotenv`) rather - than direct `os.environ` reads scattered across code. -- Validate that required secrets are present at application startup; fail fast with a - clear error rather than silently using a default. -- Rotate any secret that may have been exposed. Treat exposure as certain if the secret - ever appeared in a commit, even briefly. - -```python -# Correct: fail fast, clear message -api_key = os.environ["OPENAI_API_KEY"] # raises KeyError if missing - -# Wrong: silent fallback hides misconfiguration -api_key = os.environ.get("OPENAI_API_KEY", "") -``` - -## Dependency security - -- Pin all dependency versions in `pyproject.toml` and commit `uv.lock`. -- Review `dependabot` PRs promptly; do not let them accumulate. -- Run `just security` (if present) or `uv run pip-audit` before major releases. - -## Security response protocol - -If a security issue is discovered: - -1. Stop work on the current feature immediately. -2. Assess severity: can it expose user data, allow privilege escalation, or cause data loss? -3. For **critical** issues: open a private GitHub security advisory, do not discuss in - public issues. -4. Rotate any exposed secrets before fixing the code. -5. Write a test that reproduces the vulnerability before patching. -6. Review the entire codebase for similar patterns after fixing. - -## What is in scope for this repo - -This is a Copier template repository. Security considerations include: - -- The `_tasks` in `copier.yml` execute arbitrary shell commands on the user's machine. - Keep tasks minimal, idempotent, and auditable. -- Generated projects inherit security configuration from the template; changes to - template security configuration affect all future generated projects. -- Copier's `--trust` flag must be documented so users understand what they are consenting to. +- Never hardcode secrets (API keys, passwords, tokens) in source, config, or docs. +- Load secrets via `os.environ["KEY"]` (fail-fast); never `os.environ.get("KEY", "")`. +- Validate all external inputs at the boundary; use parameterised queries for SQL. +- Pin all dependency versions in `pyproject.toml`; commit `uv.lock`. diff --git a/.claude/rules/common/testing.md b/.claude/rules/common/testing.md index 2801702..7d2ff45 100644 --- a/.claude/rules/common/testing.md +++ b/.claude/rules/common/testing.md @@ -1,87 +1,7 @@ # Testing Requirements -## Minimum coverage: 80 % (85 % for generated projects) - -All new code requires tests. Coverage is measured at the module level; do not lower -an existing module's coverage when making changes. - -## Test types required - -| Type | Scope | Framework | -|------|-------|-----------| -| Unit | Individual functions, pure logic | pytest | -| Integration | Multi-module flows, I/O boundaries | pytest | -| Template rendering | Copier copy/update output (this repo) | pytest + copier API | - -End-to-end tests are added for critical user-facing flows when the project ships a CLI -or HTTP API. - -## Test-Driven Development workflow - -For new features and bug fixes, follow TDD: - -1. Write test — it must **fail** (RED). Use `/tdd-red` to validate. -2. Write minimal implementation — test must **pass** (GREEN). Use `/tdd-green` to validate. -3. Refactor for clarity and performance (REFACTOR). Tests must stay green after every change. -4. Validate full CI pipeline (`just ci`). Use `/ci-fix` if anything fails. -5. Verify coverage did not drop. - -Skipping the RED step (writing code before a failing test) is not TDD. - -### GREEN means minimal - -In the GREEN phase, write only enough code to make the failing test pass. Do not add error handling -for untested paths, optimisations, or features beyond what the test requires. Those belong in the -next RED cycle or in REFACTOR. Over-engineering in GREEN violates the TDD contract and introduces -untested code. - -## AAA structure (Arrange-Act-Assert) - -```python -def test_calculates_discount_for_premium_users(): - # Arrange - user = User(tier="premium") - cart = Cart(items=[Item(price=100)]) - - # Act - total = calculate_total(user, cart) - - # Assert - assert total == 90 # 10 % discount -``` - -## Naming conventions - -Use descriptive names that read as sentences: - -``` -test_returns_empty_list_when_no_results_match() -test_raises_value_error_when_email_is_invalid() -test_falls_back_to_default_when_config_is_missing() -``` - -Avoid: `test_1()`, `test_function()`, `test_ok()`. - -## Test isolation - -- Tests must not share mutable state. Each test starts from a known baseline. -- Mock external I/O (network, filesystem, clocks) at the boundary, not deep in - the call stack. -- Do not depend on test execution order. Tests must pass when run individually - and in any order. - -## What not to test - -- Implementation details that may change without affecting observable behaviour. -- Third-party library internals. -- Trivial getters/setters with no logic. - -## Running tests - -```bash -just test # all tests, quiet -just test-parallel # parallelised with pytest-xdist -just coverage # coverage report with missing lines -``` - -Always run `just ci` before opening a pull request. +- Minimum coverage: 85% per module; never lower an existing module's coverage. +- TDD workflow: write failing test (RED) → minimal implementation (GREEN) → refactor (REFACTOR). +- GREEN means minimal — write only enough code to make the failing test pass. +- Tests must not share mutable state; must pass when run individually or in any order. +- Every new public symbol requires at least one test. Use `just test` / `just coverage`. diff --git a/.claude/rules/copier/template-conventions.md b/.claude/rules/copier/template-conventions.md index 8c40e38..eb128e6 100644 --- a/.claude/rules/copier/template-conventions.md +++ b/.claude/rules/copier/template-conventions.md @@ -1,183 +1,13 @@ -# Copier Template Conventions - -# applies-to: copier.yml, template/** - -These rules apply to `copier.yml` and all files under `template/`. They are -specific to the template meta-repo and are **not** propagated to generated projects. - -## copier.yml structure - -Organise `copier.yml` into clearly labelled sections with separator comments: - -```yaml -# --- Template metadata --- -_min_copier_version: "9.11.2" -_subdirectory: template - -# --- Jinja extensions --- -_jinja_extensions: - - jinja2_time.TimeExtension - -# --- Questions / Prompts --- -project_name: - type: str - help: Human-readable project name - -# --- Computed values --- -current_year: - type: str - when: false - default: "{% now 'utc', '%Y' %}" - -# --- Post-generation tasks --- -_tasks: - - command: uv lock -``` - -## Questions (prompts) - -- Every question must have a `help:` string that clearly explains what the value is used for. -- Every question must have a sensible `default:` so users can accept defaults with `--defaults`. -- Use `choices:` for questions with a fixed set of valid answers (license, Python version). -- Use `validator:` with a Jinja expression for format validation (e.g. package name must be - a valid Python identifier). -- Use `when: "{{ some_bool }}"` to conditionally show questions that are only relevant - when a related option is enabled. - -## Computed variables - -Computed variables (not prompted) must use `when: false`: - -```yaml -current_year: - type: str - when: false - default: "{% now 'utc', '%Y' %}" -``` - -They are not stored in `.copier-answers.yml` and not shown to users. Use them for -derived values (year, Python version matrix) to keep templates DRY. - -## Secrets and third-party tokens - -Do **not** add Copier prompts for API tokens or upload keys (Codecov, PyPI, npm, -and so on). Those belong in the CI provider’s **encrypted secrets** (for example -GitHub Actions **Settings → Secrets**) and in maintainer documentation -(`README.md`, `docs/ci.md`), not in `.copier-answers.yml`. - -If you must accept a rare secret interactively, use `secret: true` with a safe -`default` so it is **not** written to the answers file — and still prefer -documenting the secret-in-CI workflow instead of prompting. - -## _skip_if_exists - -Files in `_skip_if_exists` are preserved on `copier update` — user edits are not -overwritten. Add a file here when: -- The user is expected to customise it significantly (`pyproject.toml`, `README.md`). -- Overwriting it on update would destroy user work. - -Do **not** add files here unless there is a clear reason. Too many skipped files -make updates less useful. - -## _tasks - -Post-generation tasks run after both `copier copy` and `copier update`. Design them -to be **idempotent** (safe to run multiple times) and **fast** (do not download -large artefacts unconditionally). - -Use `copier update --skip-tasks` to bypass tasks when only the template content needs -to be refreshed. - -Tasks use `/bin/sh` (POSIX shell), not bash. Use POSIX-compatible syntax. - -## Template file conventions - -- Add `.jinja` suffix to every file that contains Jinja2 expressions. -- Files without `.jinja` are copied verbatim (no Jinja processing). -- Template file names may themselves contain Jinja expressions: - `src/{{ package_name }}/__init__.py.jinja` → `src/mylib/__init__.py`. -- Keep Jinja expressions in file names simple (variable substitution only). - -## .copier-answers.yml - -- Never edit `.copier-answers.yml` by hand in generated projects. -- The answers file is managed by Copier's update algorithm. Manual edits cause - unpredictable diffs on the next `copier update`. -- The template file `{{_copier_conf.answers_file}}.jinja` generates the answers file. - Changes to its structure require careful migration testing. - -## Versioning and releases - -- Tag releases with **PEP 440** versions: `1.0.0`, `1.2.3`, `2.0.0a1`. -- `copier update` uses these tags to select the appropriate template version. -- Introduce `_migrations` in `copier.yml` when a new version renames or removes template - files, to guide users through the update. -- See `src/{{ package_name }}/common/bump_version.py` and `.github/workflows/release.yml` for the release - automation workflow. - -## Skill descriptions (if adding skills to template) - -Every skill SKILL.md frontmatter must include a `description:` field with these constraints: - -- **Max 1024 characters** (hard limit) — skill descriptions are used in Claude's command - suggestions and UI; longer descriptions are truncated and unusable. -- Use `>-` for multi-line descriptions (block scalar without trailing newline). -- Lead with the skill's **primary purpose** (what it does). -- Include **trigger keywords** if applicable (e.g., "use this when the user says..."). -- Mention **what the skill outputs** (e.g., "produces test skeletons with AAA structure"). - -**Template:** - -```yaml --- -name: skill-name -description: >- - - - - - +paths: + - "copier.yml" + - "template/**" --- -``` -**Example (tdd-test-planner):** - -```yaml -description: >- - Convert a requirement into a structured pytest test plan. - Use when the user says: "plan my tests", "write tests first", "TDD approach". - Produces categorised test cases (happy path, errors, boundaries, edges, integration) - plus pytest skeletons with AAA structure and fixture guidance. -``` - -**Measurement:** Count characters in the `description:` value (not including frontmatter). - ---- - -## Dual-hierarchy maintenance - -This repo has two parallel `.claude/` trees: - -``` -.claude/ ← used while developing the template -template/.claude/ ← rendered into generated projects -``` - -When adding or modifying hooks, commands, or rules: -- Decide whether the change applies to the template maintainer only, generated projects - only, or both. -- If both: add to both trees. The `post-edit-template-mirror.sh` hook will remind you. -- Rules specific to Copier/Jinja/YAML belong only in the root tree. -- Rules for Python/Bash/Markdown typically belong in both trees. - -## Testing template changes - -Every change to `copier.yml` or a template file requires a test update in -`tests/integration/test_template.py`. Run: - -```bash -just test # run all template tests -copier copy . /tmp/test-output --trust --defaults --vcs-ref HEAD -``` +# Copier Template Conventions -Clean up: `rm -rf /tmp/test-output` +- Never edit `.copier-answers.yml` by hand — managed by Copier's update algorithm. +- Computed variables (not prompted) use `when: false`; not stored in answers or shown to users. +- Do not prompt for API tokens or secrets; use CI encrypted secrets instead. +- Dual-hierarchy: changes affecting both meta-repo and generated projects go in both `.claude/` and `template/.claude/`. +- Every `copier.yml` or template file change requires a test update in `tests/integration/test_template.py`. diff --git a/.claude/rules/jinja/coding-style.md b/.claude/rules/jinja/coding-style.md deleted file mode 100644 index e500ab5..0000000 --- a/.claude/rules/jinja/coding-style.md +++ /dev/null @@ -1,107 +0,0 @@ -# Jinja2 Coding Style - -# applies-to: **/*.jinja - -Jinja2 templates are used in this repository to generate Python project files via -Copier. These rules apply to every `.jinja` file under `template/`. - -## Enabled extensions - -The following Jinja2 extensions are active (configured in `copier.yml`): - -- `jinja2_time.TimeExtension` — provides `{% now 'utc', '%Y' %}` for date injection. -- `jinja2.ext.do` — enables `{% do list.append(item) %}` for side-effect statements. -- `jinja2.ext.loopcontrols` — enables `{% break %}` and `{% continue %}` in loops. - -Always use these extensions rather than working around them with complex filter chains. - -## Variable substitution - -Use `{{ variable_name }}` for all substitutions. Add a trailing space inside braces -only for readability in complex expressions, not as a general rule: - -```jinja -{{ project_name }} -{{ author_name | lower | replace(" ", "-") }} -{{ python_min_version }} -``` - -## Control structures - -Indent template logic blocks consistently with the surrounding file content. -Use `{%- -%}` (dash-trimmed) tags to suppress blank lines produced by control blocks -when the rendered output should be compact: - -```jinja -{%- if include_docs %} -mkdocs: - site_name: {{ project_name }} -{%- endif %} -``` - -Use `{% if %}...{% elif %}...{% else %}...{% endif %}` for branching. -Avoid deeply nested conditions; extract to a Jinja macro or simplify the data model. - -## Whitespace control - -- Use `{%- ... -%}` to strip leading/trailing whitespace around control blocks that - should not produce blank lines in the output. -- Never strip whitespace blindly on every tag — it makes templates hard to read. -- Test rendered output with `copier copy . /tmp/test-output --trust --defaults` and - inspect for spurious blank lines before committing. - -## Filters - -Prefer built-in Jinja2 filters over custom Python logic in templates: - -| Goal | Filter | -|------|--------| -| Lowercase | `\| lower` | -| Replace characters | `\| replace("x", "y")` | -| Default value | `\| default("fallback")` | -| Join list | `\| join(", ")` | -| Trim whitespace | `\| trim` | - -Avoid complex filter chains longer than 3 steps; compute the value in `copier.yml` -as a computed variable instead. - -## Macros - -Define reusable template fragments as macros at the top of the file or in a dedicated -`_macros.jinja` file (if the project grows to warrant it): - -```jinja -{% macro license_header(year, author) %} -# Copyright (c) {{ year }} {{ author }}. All rights reserved. -{% endmacro %} -``` - -## File naming - -- Suffix: `.jinja` (e.g. `pyproject.toml.jinja`, `__init__.py.jinja`). -- File names can themselves be Jinja expressions: - `src/{{ package_name }}/__init__.py.jinja` renders to `src/mylib/__init__.py`. -- Keep file name expressions simple: variable substitution only, no filters. - -## Commenting - -Use Jinja comments (`{# comment #}`) for notes that should not appear in the rendered -output. Use the target language's comment syntax for notes that should survive rendering: - -```jinja -{# This block is only rendered when pandas support is requested #} -{% if include_pandas_support %} -pandas>=2.0 -{% endif %} - -# This Python comment will appear in the generated file -import os -``` - -## Do not embed logic that belongs in copier.yml - -Templates should be presentation, not computation. Move conditional logic to: -- `copier.yml` computed variables (`when: false`, `default: "{% if ... %}"`) -- Copier's `_tasks` for post-generation side effects - -Deeply nested `{% if %}{% if %}{% if %}` blocks in a template are a signal to refactor. diff --git a/.claude/rules/jinja/testing.md b/.claude/rules/jinja/testing.md deleted file mode 100644 index 0f1feff..0000000 --- a/.claude/rules/jinja/testing.md +++ /dev/null @@ -1,91 +0,0 @@ -# Jinja2 Template Testing - -# applies-to: **/*.jinja - -> This file extends [common/testing.md](../common/testing.md) with Jinja2-specific content. - -## What to test - -Every Jinja2 template change requires a corresponding test in `tests/integration/test_template.py`. -Tests render the template with `copier copy` and assert on the output. - -Scenarios to cover for each template file: - -1. **Default values** — render with `--defaults` and assert the file exists with - expected content. -2. **Boolean feature flags** — render with each combination of `include_*` flags that - affects the template and assert the relevant sections are present or absent. -3. **Variable substitution** — render with non-default values (e.g. a custom - `project_name`) and assert the value appears correctly in the output. -4. **Whitespace correctness** — spot-check that blank lines are not spuriously added or - removed by whitespace-control tags. - -## Test utilities - -Tests use the `copier` Python API directly (not the CLI) for reliability: - -```python -from copier import run_copy - -def test_renders_with_pandas(tmp_path): - run_copy( - src_path=str(ROOT), - dst_path=str(tmp_path), - data={"include_pandas_support": True}, - defaults=True, - overwrite=True, - unsafe=True, - vcs_ref="HEAD", - ) - pyproject = (tmp_path / "pyproject.toml").read_text() - assert "pandas" in pyproject -``` - -The `ROOT` constant points to the repository root. Use the `tmp_path` fixture for the -destination directory so pytest cleans it up automatically. - -## Syntax validation - -The `post-edit-jinja.sh` PostToolUse hook validates Jinja2 syntax automatically after -every `.jinja` file edit in a Claude Code session: - -``` -┌─ Jinja2 syntax check: template/pyproject.toml.jinja -│ ✓ Jinja2 syntax OK -└─ Done -``` - -If the hook reports a syntax error, fix it before running tests — `copier copy` will -fail with a less helpful error message if template syntax is broken. - -## Manual rendering for inspection - -Render the full template to inspect a complex template change: - -```bash -copier copy . /tmp/test-output --trust --defaults --vcs-ref HEAD -``` - -Inspect specific files: - -```bash -cat /tmp/test-output/pyproject.toml -cat /tmp/test-output/src/my_library/__init__.py -``` - -Clean up: - -```bash -rm -rf /tmp/test-output -``` - -## Update testing - -Test `copier update` scenarios when changing `_skip_if_exists` or the `.copier-answers.yml` -template. The `tests/integration/test_template.py` file includes update scenario tests; add new ones -when you add new `_skip_if_exists` entries. - -## Coverage for template branches - -Aim to cover every `{% if %}` branch in every template file with at least one test. -Untested branches can produce invalid code in generated projects. diff --git a/.claude/rules/markdown/conventions.md b/.claude/rules/markdown/conventions.md index eb3b25e..dcd4939 100644 --- a/.claude/rules/markdown/conventions.md +++ b/.claude/rules/markdown/conventions.md @@ -1,82 +1,12 @@ -# Markdown Conventions - -# applies-to: **/*.md - -## File placement rule - -Any Markdown file created as part of a workflow, analysis, or investigation output -**must be placed inside the `docs/` folder**. - -**Allowed exceptions** (may be placed at the repository root or any location): -- `README.md` -- `CLAUDE.md` -- `.claude/rules/**/*.md` (rules documentation) -- `.claude/hooks/README.md` (hooks documentation) -- `.github/**/*.md` (GitHub community files) - -Do **not** create free-standing files such as `ANALYSIS.md`, `NOTES.md`, or -`LOGGING_ANALYSIS.md` at the repository root or inside `src/`, `tests/`, or `scripts/`. - -This rule is enforced by: -- `pre-write-doc-file-warning.sh` (PreToolUse: blocks writing `.md` outside allowed locations) -- `post-edit-markdown.sh` (PostToolUse: warns if an existing `.md` is edited in the wrong place) - -## Headings - -- Use ATX headings (`#`, `##`, `###`), not Setext underlines (`===`, `---`). -- One top-level heading (`# Title`) per file. -- Do not skip heading levels (e.g. `##` → `####` without a `###` in between). -- Headings should be sentence-case (capitalise first word only) unless the subject - is a proper noun or acronym. - -## Line length and wrapping - -- Wrap prose at 100 characters for readability in editors and diffs. -- Do not wrap code blocks, tables, or long URLs. - -## Code blocks +--- +paths: + - "**/*.md" + - "**/*.mdx" +--- -- Always specify the language for fenced code blocks: - ``` - \```python - \```bash - \```yaml - \``` -- Use inline code (backticks) for: file names, directory names, command names, - variable names, and short code snippets within prose. - -## Lists - -- Use `-` for unordered lists (not `*` or `+`). -- Use `1.` for all items in ordered lists (the renderer handles numbering). -- Nest lists with 2-space or 4-space indentation consistently within a file. - -## Tables - -- Align columns with spaces for readability in source (optional but preferred). -- Include a header row and a separator row. -- Keep tables narrow enough to read without horizontal scrolling. - -## Links - -- Use reference-style links for URLs that appear more than once. -- Use relative links for internal project files: - `[CLAUDE.md](../CLAUDE.md)` not `https://github.com/…/CLAUDE.md`. -- Do not embed bare URLs in prose; always use `[descriptive text](url)`. - -## CLAUDE.md maintenance - -`CLAUDE.md` (root and `template/CLAUDE.md.jinja`) is the primary context document -for AI assistants. Keep it up to date: - -- Run `/update-claude-md` slash command to detect drift against `pyproject.toml` - and the `justfile`. -- Update `CLAUDE.md` when you add or change slash commands, hooks, tooling, or - project structure. -- Do not duplicate content between `CLAUDE.md` and the rules files. Cross-reference - with a link instead. - -## Generated project documentation +# Markdown Conventions -Documentation for generated projects lives in `docs/` (MkDocs) and follows the same -conventions. The `docs/index.md.jinja` template is the starting point. +- Output files belong in `docs/`. Exceptions: `README.md`, `CLAUDE.md`, `.claude/**/*.md`, `.github/**/*.md`. +- Never create free-standing files (e.g. `ANALYSIS.md`, `NOTES.md`) at the repo root, `src/`, `tests/`, or `scripts/`. +- Use ATX headings (`#`), sentence-case, one `# Title` per file; `-` for unordered lists. +- Wrap prose at 100 characters; always specify a language on fenced code blocks. diff --git a/.claude/rules/python/coding-style.md b/.claude/rules/python/coding-style.md index c7dba38..d9e8db0 100644 --- a/.claude/rules/python/coding-style.md +++ b/.claude/rules/python/coding-style.md @@ -1,145 +1,14 @@ -# Python Coding Style - -# applies-to: **/*.py, **/*.pyi - -> This file extends [common/coding-style.md](../common/coding-style.md) with Python-specific content. - -## Formatter and linter - -- **ruff** is the single tool for both formatting and linting. Do not use black, isort, - or flake8 alongside it — they will conflict. -- Run `just fmt` to format, `just lint` to lint, `just fix` to auto-fix safe violations. -- Active rule sets: `E`, `F`, `I`, `UP`, `B`, `SIM`, `C4`, `RUF`, `D`, `C90`, `PERF`. -- Line length: 100 characters. `E501` is disabled (formatter handles wrapping). - -## Type annotations - -All public functions and methods must have complete type annotations: - -```python -# Correct -def calculate_discount(price: float, tier: str) -> float: - ... - -# Wrong — missing annotations -def calculate_discount(price, tier): - ... -``` - -- Use `basedpyright` in `standard` mode for type checking (`just type`). -- Prefer `X | Y` union syntax (Python 3.10+) over `Optional[X]` / `Union[X, Y]`. -- Use `from __future__ import annotations` only when needed for forward references in - Python 3.11; prefer the native syntax. - -## Docstrings — Google style - -Every public function, class, and method must have a Google-style docstring: - -```python -def fetch_user(user_id: int, *, include_deleted: bool = False) -> User | None: - """Fetch a user by ID from the database. - - Args: - user_id: The primary key of the user to retrieve. - include_deleted: When True, soft-deleted users are also returned. - - Returns: - The matching User instance, or None if not found. - - Raises: - DatabaseError: If the database connection fails. - """ -``` - -- `Args`, `Returns`, `Raises`, `Yields`, `Note`, `Example` are the supported sections. -- Test files (`tests/**`) and scripts (`scripts/**`) are exempt from docstring requirements. - -## Naming - -| Symbol | Convention | Example | -|--------|-----------|---------| -| Module | `snake_case` | `file_manager.py` | -| Class | `PascalCase` | `LoggingManager` | -| Function / method | `snake_case` | `configure_logging()` | -| Variable | `snake_case` | `retry_count` | -| Constant | `UPPER_SNAKE_CASE` | `MAX_RETRIES` | -| Type alias | `PascalCase` | `UserId = int` | -| Private | leading `_` | `_internal_helper()` | -| Dunder | `__name__` | `__all__`, `__init__` | - -## Immutability +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- -Prefer immutable data structures: - -```python -from dataclasses import dataclass -from typing import NamedTuple - -@dataclass(frozen=True) -class Config: - host: str - port: int - -class Point(NamedTuple): - x: float - y: float -``` - -## Error handling - -```python -# Correct: specific exception, meaningful message -try: - result = parse_config(path) -except FileNotFoundError: - raise ConfigError(f"Config file not found: {path}") from None -except (ValueError, KeyError) as exc: - raise ConfigError(f"Malformed config: {exc}") from exc - -# Wrong: silently swallowed -try: - result = parse_config(path) -except Exception: - result = None -``` - -## Logging - -Use **structlog** (configured via `common.logging_manager`). Never use `print()` or the -standard `logging.getLogger()` in application code. - -```python -import structlog -log = structlog.get_logger() - -log.info("user_created", user_id=user.id, email=user.email) -log.error("payment_failed", order_id=order_id, reason=str(exc), llm=True) -``` - -## Imports - -```python -# Correct order: stdlib → third-party → local -import os -from pathlib import Path - -import structlog - -from mypackage.common.utils import slugify -``` - -Avoid wildcard imports (`from module import *`) except in `__init__.py` for re-exporting. - -## Collections and comprehensions - -Prefer comprehensions over `map`/`filter` for readability: - -```python -# Preferred -active_users = [u for u in users if u.is_active] - -# Avoid unless the lambda is trivial -active_users = list(filter(lambda u: u.is_active, users)) -``` +# Python Coding Style -Use generator expressions when you only iterate once and do not need a list in memory. +- All public functions, methods, and classes require complete type annotations and a Google-style docstring. +- Use `structlog.get_logger()` for logging in `src/`; never `print()`, `logging.getLogger()`, or `logging.basicConfig()`. +- Call `configure_logging()` once at entry point; use public APIs from `my_library.common.logging_manager`. +- Prefer `X | Y` union syntax; basedpyright `standard` mode enforced — run `just type` after edits. +- Run `just fmt` and `just lint` after edits; active ruff rules include `D`, `C90`, `PERF`, `T20`. +- Outside `common/`, prefer imports from `my_library.common` over reimplementing file I/O, decorators, or utils. diff --git a/.claude/rules/python/hooks.md b/.claude/rules/python/hooks.md index 15170e5..ad04ac6 100644 --- a/.claude/rules/python/hooks.md +++ b/.claude/rules/python/hooks.md @@ -1,114 +1,12 @@ -# Python Hooks - -# applies-to: **/*.py, **/*.pyi - -> This file extends [common/hooks.md](../common/hooks.md) with Python-specific content. - -## PostToolUse hooks active for Python files - -These hooks fire automatically in a Claude Code session after any `.py` file is -edited or created. They provide immediate feedback so violations are fixed in the -same turn, not at CI time. - -| Hook script | Trigger | What it does | -|-------------|---------|--------------| -| `post-edit-python.sh` | Edit or Write on `*.py` | Runs `ruff check` + `basedpyright` on the saved file; prints violations to stdout | - -### What the hook checks - -1. **ruff check** — all active rule sets: `E`, `F`, `I`, `UP`, `B`, `SIM`, `C4`, `RUF`, - `D` (docstrings), `C90` (complexity), `PERF` (performance anti-patterns), - `T20` / `T201` (no `print()` in app code). -2. **basedpyright** — type correctness in `standard` mode. - -If either tool reports violations, the hook prints a structured block: - -``` -┌─ Standards check: src/mypackage/core.py -│ -│ ruff check -│ src/mypackage/core.py:42:5: D401 First line should be in imperative mood -│ -│ basedpyright -│ src/mypackage/core.py:17:12: error: Type "str | None" cannot be assigned to "str" -│ -└─ ✗ Violations found — fix before committing -``` - -Fix all reported violations before moving on to the next file. - -## Pre-commit hooks active for Python files - -The following hooks run on every `git commit` via pre-commit: - -| Hook | What it does | -|------|-------------| -| `ruff` | Lint + format check on staged `.py` files | -| `basedpyright` | Type check on the entire `src/` tree | -| `pre-bash-commit-quality.sh` | Scan staged `.py` files for hardcoded secrets and debug statements | - -The `pre-bash-block-no-verify.sh` hook blocks `git commit --no-verify`, ensuring -pre-commit hooks cannot be skipped. - -## Warning: `print()` in application code - -Using `print()` in `src/` is a violation (ruff `T201`). Use `structlog` instead: +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- -```python -# Wrong -print(f"Processing order {order_id}") - -# Correct -log = structlog.get_logger() -log.info("processing_order", order_id=order_id) -``` - -`print()` is allowed in `scripts/` and in test files. - -## TDD enforcement hooks (PreToolUse) - -Register **at most one** of the source/test hooks below for `Write|Edit`. They use -the same scope; running both would warn and then block on the same condition. - -| Hook | Trigger | What it does | -|------|---------|----------------| -| `pre-write-src-require-test.sh` | `Write` or `Edit` on `src//.py` | **Blocks** write if `tests//test_.py` does not exist (strict TDD). **Registered by default** in `.claude/settings.json`. | -| `pre-write-src-test-reminder.sh` | Same | Warns only (non-blocking). Swap into `settings.json` **instead of** `pre-write-src-require-test.sh` if you want reminders without blocking. | -| `pre-bash-coverage-gate.sh` | `Bash` on `git commit` | Warns if test coverage is below 85% threshold | - -Both source/test hooks only check top-level package modules (`src//.py`, -excluding `__init__.py`). Nested packages are skipped. - -### How to swap to the warn-only reminder - -The default strict hook (`pre-write-src-require-test.sh`) blocks any write to -`src//.py` when the matching test file is missing. If you prefer a non-blocking -reminder, swap the registration in `.claude/settings.json`: - -1. Locate the `PreToolUse` entry whose `command` is - `bash .claude/hooks/pre-write-src-require-test.sh`. -2. Replace `pre-write-src-require-test.sh` with `pre-write-src-test-reminder.sh` in that - entry. -3. Register only one at a time. Registering both produces duplicate output on every write. - -## Refactor test guard (PostToolUse) - -| Hook | Trigger | What it does | -|------|---------|----------------| -| `post-edit-refactor-test-guard.sh` | `Edit` or `Write` on `src/**/*.py` | Reminds to run tests after every 3 source edits | - -Tracks edit count since last test run. Helps maintain GREEN during the REFACTOR phase. - -## Type-checking configuration - -basedpyright settings live in `pyproject.toml` under `[tool.basedpyright]`: - -```toml -[tool.basedpyright] -pythonVersion = "3.11" -typeCheckingMode = "standard" -reportMissingTypeStubs = false -``` +# Python Hooks -Do not weaken `typeCheckingMode` or add broad `# type: ignore` comments without -a specific error code and a comment explaining why it is necessary. +- `post-edit-python.sh` runs ruff + basedpyright after every `.py` edit — fix all violations before moving on. +- `pre-write-src-require-test.sh` blocks writing `src//.py` if a matching test file is missing. +- `pre-bash-coverage-gate.sh` warns before `git commit` if coverage is below 85%. +- Do not weaken `typeCheckingMode` in `pyproject.toml`; never add broad `# type: ignore` without a specific error code. diff --git a/.claude/rules/python/patterns.md b/.claude/rules/python/patterns.md deleted file mode 100644 index a260b19..0000000 --- a/.claude/rules/python/patterns.md +++ /dev/null @@ -1,137 +0,0 @@ -# Python Patterns - -# applies-to: **/*.py, **/*.pyi - -> This file extends [common/patterns.md](../common/patterns.md) with Python-specific content. - -## Dataclasses as data transfer objects - -Use `@dataclass` (or `@dataclass(frozen=True)`) for plain data containers. -Use `NamedTuple` when immutability and tuple unpacking are both needed: - -```python -from dataclasses import dataclass, field - -@dataclass -class CreateOrderRequest: - user_id: int - items: list[str] = field(default_factory=list) - notes: str | None = None - -@dataclass(frozen=True) -class Money: - amount: float - currency: str = "USD" -``` - -## Protocol for duck typing - -Prefer `Protocol` over abstract base classes for interface definitions: - -```python -from typing import Protocol - -class Repository(Protocol): - def find_by_id(self, id: int) -> dict | None: ... - def save(self, entity: dict) -> dict: ... - def delete(self, id: int) -> None: ... -``` - -## Context managers for resource management - -Use context managers (`with` statement) for all resources that need cleanup: - -```python -# Files -with open(path, encoding="utf-8") as fh: - content = fh.read() - -# Custom resources: implement __enter__ / __exit__ or use @contextmanager -from contextlib import contextmanager - -@contextmanager -def managed_connection(url: str): - conn = connect(url) - try: - yield conn - finally: - conn.close() -``` - -## Generators for lazy evaluation - -Use generators instead of building full lists when iterating once: - -```python -def read_lines(path: Path) -> Generator[str, None, None]: - with open(path, encoding="utf-8") as fh: - yield from fh - -# Caller controls materialisation -lines = list(read_lines(log_path)) # full list when needed -first_error = next( - (l for l in read_lines(log_path) if "ERROR" in l), None -) -``` - -## Dependency injection over globals - -Pass dependencies as constructor arguments or function parameters. Avoid module-level -singletons that cannot be replaced in tests: - -```python -# Preferred -class OrderService: - def __init__(self, repo: Repository, logger: BoundLogger) -> None: - self._repo = repo - self._log = logger - -# Avoid -class OrderService: - _repo = GlobalRepository() # hard to test, hard to swap -``` - -## Configuration objects - -Centralise configuration in a typed dataclass or pydantic model loaded once at startup: - -```python -@dataclass(frozen=True) -class AppConfig: - database_url: str - log_level: str = "INFO" - max_retries: int = 3 - - @classmethod - def from_env(cls) -> "AppConfig": - return cls( - database_url=os.environ["DATABASE_URL"], - log_level=os.environ.get("LOG_LEVEL", "INFO"), - ) -``` - -## Exception hierarchy - -Define a project-level base exception and derive domain-specific exceptions from it: - -```python -class AppError(Exception): - """Base class for all application errors.""" - -class ConfigError(AppError): - """Raised when configuration is missing or invalid.""" - -class DatabaseError(AppError): - """Raised when a database operation fails.""" -``` - -Catch `AppError` at the top level; catch specific subclasses where recovery is possible. - -## `__all__` for public API - -Define `__all__` in every package `__init__.py` to make the public interface explicit: - -```python -# src/mypackage/__init__.py -__all__ = ["AppContext", "configure_logging", "AppError"] -``` diff --git a/.claude/rules/python/security.md b/.claude/rules/python/security.md deleted file mode 100644 index d0f7bd6..0000000 --- a/.claude/rules/python/security.md +++ /dev/null @@ -1,109 +0,0 @@ -# Python Security - -# applies-to: **/*.py, **/*.pyi - -> This file extends [common/security.md](../common/security.md) with Python-specific content. - -## Secret management - -Load secrets from environment variables; never hardcode them: - -```python -import os - -# Correct: fails immediately with a clear error if the secret is missing -api_key = os.environ["OPENAI_API_KEY"] - -# Wrong: silently falls back to an empty string, masking misconfiguration -api_key = os.environ.get("OPENAI_API_KEY", "") -``` - -For development, use `python-dotenv` to load a `.env` file that is listed in `.gitignore`: - -```python -from dotenv import load_dotenv -load_dotenv() # reads .env if present; silently skips if absent -api_key = os.environ["OPENAI_API_KEY"] -``` - -Never commit `.env` files. Add them to `.gitignore` and document required variables in -`.env.example` with placeholder values. - -## Input validation - -Validate all external inputs at the boundary before passing them into application logic: - -```python -# Correct: validate early, raise with context -def process_order(order_id: str) -> Order: - if not order_id.isalnum(): - raise ValueError(f"Invalid order ID format: {order_id!r}") - ... - -# Wrong: trusting input, using it unvalidated -def process_order(order_id: str) -> Order: - return db.query(f"SELECT * FROM orders WHERE id = '{order_id}'") -``` - -## SQL injection prevention - -Always use parameterised queries: - -```python -# Correct -cursor.execute("SELECT * FROM users WHERE email = %s", (email,)) - -# Wrong — SQL injection vulnerability -cursor.execute(f"SELECT * FROM users WHERE email = '{email}'") -``` - -## Path traversal prevention - -Sanitise file paths from user input: - -```python -from pathlib import Path - -base_dir = Path("/app/uploads").resolve() -user_path = (base_dir / user_input).resolve() - -if not str(user_path).startswith(str(base_dir)): - raise PermissionError("Path traversal detected") -``` - -## Static security analysis - -Run **bandit** before release to catch common Python security issues: - -```bash -uv run bandit -r src/ -ll # report issues at medium severity and above -``` - -Bandit is not a substitute for code review, but it catches common patterns like -hardcoded passwords, use of `shell=True` in subprocess calls, and insecure random. - -## Subprocess calls - -Avoid `shell=True` when calling external processes; it enables shell injection: - -```python -import subprocess - -# Correct: list form, no shell expansion -result = subprocess.run(["git", "status"], capture_output=True, check=True) - -# Wrong: shell=True + user-controlled input = injection -result = subprocess.run(f"git {user_cmd}", shell=True, check=True) -``` - -## Cryptography - -- Do not implement cryptographic primitives. Use `cryptography` or `hashlib` for - standard algorithms. -- Use `secrets` (stdlib) for generating tokens, not `random`. -- Use bcrypt or Argon2 for password hashing; never SHA-256 or MD5 for passwords. - -```python -import secrets -token = secrets.token_urlsafe(32) # cryptographically secure -``` diff --git a/.claude/rules/python/testing.md b/.claude/rules/python/testing.md index 176667d..f6fbabe 100644 --- a/.claude/rules/python/testing.md +++ b/.claude/rules/python/testing.md @@ -1,148 +1,13 @@ -# Python Testing - -# applies-to: **/*.py, **/*.pyi - -> This file extends [common/testing.md](../common/testing.md) with Python-specific content. - -## Framework: pytest - -Use **pytest** exclusively. Do not use `unittest.TestCase` for new tests. Mixing styles -in the same file is not allowed. - -## Directory layout - -``` -tests/ -├── conftest.py # shared fixtures -├── test_core.py # tests for src//core.py -├── / -│ ├── test_module_a.py -│ └── test_module_b.py -└── test_imports.py # smoke test: every public symbol is importable -``` - -### Meta-repo (this template repository) - -Generated projects follow the layout above. **This Copier meta-repository** has no `src/` tree; -Python under test lives in `scripts/`. Keep pytest modules organized as: - -``` -tests/ -├── conftest.py # top-level shared fixtures -├── unit/ -│ ├── conftest.py -│ └── test_ + + +``` + +Head rules: `charset` FIRST, always include `viewport`, use the pattern +`Page Name – Site Name` for titles, load CSS in `` and scripts at the end +of `` (or with `defer`), use HTTPS for externals, omit `type="text/css"` +and `type="text/javascript"` (HTML5 defaults apply). + +### 2. Reach for semantic elements first + +Use the element built for the job. Semantic HTML gives you keyboard support, +screen reader hints, and document structure for free. + +| Element | Purpose | +|-------------------------------|--------------------------------------------------| +| `
` / `