diff --git a/.claude/rules/README.md b/.claude/rules/README.md new file mode 100644 index 0000000..82728a2 --- /dev/null +++ b/.claude/rules/README.md @@ -0,0 +1,108 @@ +# 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. + +## Structure + +Rules are organised into a **common** layer plus **language/tool-specific** directories: + +``` +.claude/rules/ +├── README.md ← you are here +├── common/ # Universal principles — apply to all code in this repo +│ ├── 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 +├── bash/ # Shell script-specific +│ ├── coding-style.md +│ └── security.md +├── 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 +``` + +## 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. + +- `common/` defines universal defaults. +- Language directories (`python/`, `jinja/`, `bash/`, …) override those defaults where + language idioms differ. + +## Dual-hierarchy reminder + +This Copier meta-repo has **two parallel rule trees**: + +``` +.claude/rules/ ← active when DEVELOPING this template repo +template/.claude/rules/ ← rendered into every GENERATED project +``` + +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). + +## How to write a new rule + +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. + +2. **File name** — use lowercase kebab-case matching the topic: `coding-style.md`, + `testing.md`, `patterns.md`, `security.md`, `hooks.md`, `performance.md`. + +3. **Opening line** — if the file extends a common counterpart, start with: + ``` + > This file extends [common/xxx.md](../common/xxx.md) with specific content. + ``` + +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. + +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 + ``` + +6. **Mirror to `template/.claude/rules/`** if the rule is relevant to generated projects. + +7. **Update this README** when adding a new language directory or a new top-level file. + +## 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 | +| 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 | + +Because rules are plain Markdown, they are readable by any tool without conversion. diff --git a/.claude/rules/bash/coding-style.md b/.claude/rules/bash/coding-style.md new file mode 100644 index 0000000..843c848 --- /dev/null +++ b/.claude/rules/bash/coding-style.md @@ -0,0 +1,135 @@ +# 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`. diff --git a/.claude/rules/bash/security.md b/.claude/rules/bash/security.md new file mode 100644 index 0000000..7bfee6d --- /dev/null +++ b/.claude/rules/bash/security.md @@ -0,0 +1,92 @@ +# 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 + +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. + +The `pre-bash-block-no-verify.sh` hook blocks `git commit --no-verify` to ensure +pre-commit security gates cannot be bypassed. diff --git a/.claude/rules/common/code-review.md b/.claude/rules/common/code-review.md new file mode 100644 index 0000000..b0f2560 --- /dev/null +++ b/.claude/rules/common/code-review.md @@ -0,0 +1,77 @@ +# 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 new file mode 100644 index 0000000..a6ed19a --- /dev/null +++ b/.claude/rules/common/coding-style.md @@ -0,0 +1,64 @@ +# 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. diff --git a/.claude/rules/common/development-workflow.md b/.claude/rules/common/development-workflow.md new file mode 100644 index 0000000..76e572a --- /dev/null +++ b/.claude/rules/common/development-workflow.md @@ -0,0 +1,85 @@ +# 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 new file mode 100644 index 0000000..e4170c1 --- /dev/null +++ b/.claude/rules/common/git-workflow.md @@ -0,0 +1,59 @@ +# 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. + +## 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. diff --git a/.claude/rules/common/security.md b/.claude/rules/common/security.md new file mode 100644 index 0000000..2c3e043 --- /dev/null +++ b/.claude/rules/common/security.md @@ -0,0 +1,63 @@ +# Security Guidelines + +## 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. diff --git a/.claude/rules/common/testing.md b/.claude/rules/common/testing.md new file mode 100644 index 0000000..ba005ac --- /dev/null +++ b/.claude/rules/common/testing.md @@ -0,0 +1,79 @@ +# 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). +2. Write minimal implementation — test must **pass** (GREEN). +3. Refactor for clarity and performance (IMPROVE). +4. Verify coverage did not drop. + +Skipping the RED step (writing code before a failing test) is not TDD. + +## 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. diff --git a/.claude/rules/copier/template-conventions.md b/.claude/rules/copier/template-conventions.md new file mode 100644 index 0000000..7dea6ff --- /dev/null +++ b/.claude/rules/copier/template-conventions.md @@ -0,0 +1,146 @@ +# 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. + +## Secret variables + +Use `secret: true` for sensitive values to prevent them from being stored in +`.copier-answers.yml`: + +```yaml +codecov_token: + type: str + secret: true + default: "" + help: Leave empty; use GitHub secret CODECOV_TOKEN +``` + +## _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 `scripts/bump_version.py` and `.github/workflows/release.yml` for the release + automation workflow. + +## 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/test_template.py`. Run: + +```bash +just test # run all template tests +copier copy . /tmp/test-output --trust --defaults --vcs-ref HEAD +``` + +Clean up: `rm -rf /tmp/test-output` diff --git a/.claude/rules/jinja/coding-style.md b/.claude/rules/jinja/coding-style.md new file mode 100644 index 0000000..e500ab5 --- /dev/null +++ b/.claude/rules/jinja/coding-style.md @@ -0,0 +1,107 @@ +# 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 new file mode 100644 index 0000000..404b299 --- /dev/null +++ b/.claude/rules/jinja/testing.md @@ -0,0 +1,91 @@ +# 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/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/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 new file mode 100644 index 0000000..eb3b25e --- /dev/null +++ b/.claude/rules/markdown/conventions.md @@ -0,0 +1,82 @@ +# 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 + +- 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 + +Documentation for generated projects lives in `docs/` (MkDocs) and follows the same +conventions. The `docs/index.md.jinja` template is the starting point. diff --git a/.claude/rules/python/coding-style.md b/.claude/rules/python/coding-style.md new file mode 100644 index 0000000..c7dba38 --- /dev/null +++ b/.claude/rules/python/coding-style.md @@ -0,0 +1,145 @@ +# 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 + +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)) +``` + +Use generator expressions when you only iterate once and do not need a list in memory. diff --git a/.claude/rules/python/hooks.md b/.claude/rules/python/hooks.md new file mode 100644 index 0000000..87905d1 --- /dev/null +++ b/.claude/rules/python/hooks.md @@ -0,0 +1,79 @@ +# 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). +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: + +```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. + +## Type-checking configuration + +basedpyright settings live in `pyproject.toml` under `[tool.basedpyright]`: + +```toml +[tool.basedpyright] +pythonVersion = "3.11" +typeCheckingMode = "standard" +reportMissingTypeStubs = false +``` + +Do not weaken `typeCheckingMode` or add broad `# type: ignore` comments without +a specific error code and a comment explaining why it is necessary. diff --git a/.claude/rules/python/patterns.md b/.claude/rules/python/patterns.md new file mode 100644 index 0000000..a260b19 --- /dev/null +++ b/.claude/rules/python/patterns.md @@ -0,0 +1,137 @@ +# 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 new file mode 100644 index 0000000..d0f7bd6 --- /dev/null +++ b/.claude/rules/python/security.md @@ -0,0 +1,109 @@ +# 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 new file mode 100644 index 0000000..887c1eb --- /dev/null +++ b/.claude/rules/python/testing.md @@ -0,0 +1,115 @@ +# 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 +``` + +Files must be named `test_.py`. Test functions must start with `test_`. + +## Running tests + +```bash +just test # pytest -q +just test-parallel # pytest -q -n auto (pytest-xdist) +just coverage # pytest --cov=src --cov-report=term-missing +``` + +## Fixtures + +Define shared fixtures in `conftest.py`. Use function scope unless a fixture is +explicitly expensive and safe to share: + +```python +# conftest.py +import pytest +from mypackage.core import AppContext + +@pytest.fixture() +def app_context() -> AppContext: + ctx = AppContext() + yield ctx + ctx.close() +``` + +Use `tmp_path` (built-in pytest fixture) for temporary file operations. Never use +`/tmp` directly in tests. + +## Parametrised tests + +Use `@pytest.mark.parametrize` instead of looping inside a test function: + +```python +@pytest.mark.parametrize(("input_val", "expected"), [ + ("hello world", "hello-world"), + (" leading", "leading"), + ("UPPER", "upper"), +]) +def test_slugify(input_val: str, expected: str) -> None: + assert slugify(input_val) == expected +``` + +## Marks for categorisation + +```python +@pytest.mark.unit +def test_pure_logic(): ... + +@pytest.mark.integration +def test_reads_from_disk(): ... + +@pytest.mark.slow +def test_full_pipeline(): ... +``` + +Run a subset: `uv run pytest -m unit`. + +## Mocking + +Use `unittest.mock` (or `pytest-mock`'s `mocker` fixture). Mock at the boundary — +mock the I/O call, not an internal helper: + +```python +def test_fetch_user_returns_none_when_not_found(mocker): + mocker.patch("mypackage.db.execute", return_value=[]) + result = fetch_user(user_id=99) + assert result is None +``` + +Do not patch implementation details that are not part of the public interface. + +## Coverage requirements + +- Overall threshold: ≥ 80 % (≥ 85 % for generated projects). +- New modules introduced in a PR must meet the threshold. +- Coverage is measured on `src/` only; test files are excluded from the report. + +Configuration lives in `pyproject.toml` under `[tool.coverage.*]`. + +## What to assert + +- Assert on **observable behaviour**, not implementation steps. +- Avoid asserting on log output or internal state unless that is the feature under test. +- Use `pytest.raises` for exception testing: + +```python +def test_raises_on_invalid_email(): + with pytest.raises(ValueError, match="invalid email"): + validate_email("not-an-email") +``` diff --git a/.claude/rules/yaml/conventions.md b/.claude/rules/yaml/conventions.md new file mode 100644 index 0000000..0706c20 --- /dev/null +++ b/.claude/rules/yaml/conventions.md @@ -0,0 +1,76 @@ +# YAML Conventions + +# applies-to: **/*.yml, **/*.yaml + +YAML files in this repository include `copier.yml`, GitHub Actions workflows +(`.github/workflows/*.yml`), `mkdocs.yml`, and `.pre-commit-config.yaml`. + +## Formatting + +- Indentation: 2 spaces. Never use tabs. +- No trailing whitespace on any line. +- End each file with a single newline. +- Wrap long string values with block scalars (`|` or `>`) rather than quoted strings + when the value spans multiple lines. + +```yaml +# Preferred for multiline strings +description: >- + A long description that wraps cleanly + and is easy to read in source. + +# Avoid +description: "A long description that wraps cleanly and is easy to read in source." +``` + +## Quoting strings + +- Quote strings that contain YAML special characters: `:`, `{`, `}`, `[`, `]`, + `,`, `#`, `&`, `*`, `?`, `|`, `-`, `<`, `>`, `=`, `!`, `%`, `@`, `\`. +- Quote strings that could be misinterpreted as other types (`"true"`, `"1.0"`, + `"null"`). +- Do not quote simple alphanumeric strings unnecessarily. + +## Booleans and nulls + +Use YAML 1.2 style (as recognised by Copier and most modern parsers): +- Boolean: `true` / `false` (lowercase, unquoted). +- Null: `null` or `~`. +- Avoid the YAML 1.1 aliases (`yes`, `no`, `on`, `off`) — they are ambiguous. + +## Comments + +- Use `#` comments to explain non-obvious configuration choices. +- Separate logical sections with a blank line and a comment header: + +```yaml +# ------------------------------------------------------------------------- +# Post-generation tasks +# ------------------------------------------------------------------------- +_tasks: + - command: uv lock +``` + +## GitHub Actions specific + +- Pin third-party actions to a full commit SHA, not a floating tag: + ```yaml + uses: actions/checkout@v4 # acceptable if you review tag-SHA mapping + uses: actions/checkout@abc1234 # preferred for production workflows + ``` +- Use `env:` at the step or job level for environment variables; avoid top-level `env:` + unless the variable is used across all jobs. +- Name every step with a descriptive `name:` field. +- Prefer `actions/setup-python` with an explicit `python-version` matrix over hardcoded + versions. + +## copier.yml specific + +See [copier/template-conventions.md](../copier/template-conventions.md) for Copier-specific +YAML conventions. Rules here cover general YAML style; Copier semantics are covered there. + +## .pre-commit-config.yaml specific + +- Pin `rev:` to a specific version tag, not `HEAD` or `latest`. +- Group hooks by repository with a blank line between repos. +- List hooks in logical order: formatters before linters, linters before type checkers. diff --git a/CLAUDE.md b/CLAUDE.md index 211b825..44eec2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,26 @@ file, add a corresponding test. - Type annotations are required on all public functions and methods (basedpyright `standard` mode). - BasedPyright is lenient with external packages (`reportMissingTypeStubs = false`). +## AI rules + +Detailed coding standards are documented as plain Markdown files under `.claude/rules/` +and are readable by any AI assistant (Claude Code, Cursor, or any LLM): + +``` +.claude/rules/ +├── README.md ← how to read and write rules +├── common/ ← language-agnostic: coding-style, git-workflow, testing, security, … +├── python/ ← Python: coding-style, testing, patterns, security, hooks +├── jinja/ ← Jinja2: coding-style, testing +├── bash/ ← Bash: coding-style, security +├── markdown/ ← placement rules, authoring conventions +├── yaml/ ← YAML formatting for copier.yml and workflows +└── copier/ ← Copier template conventions (this repo only) +``` + +The `template/.claude/rules/` tree mirrors this structure for generated projects +(common, python, bash, markdown — no Jinja or Copier-specific rules). + ## Standards enforcement Standards are enforced at four layers — during development, at commit, in review, and in CI. diff --git a/template/.claude/rules/README.md b/template/.claude/rules/README.md new file mode 100644 index 0000000..41bf774 --- /dev/null +++ b/template/.claude/rules/README.md @@ -0,0 +1,53 @@ +# AI Rules — Developer Guide + +This directory contains the rules that inform any AI assistant (Claude Code, Cursor, etc.) +working in this project. Rules are plain Markdown files — readable by any tool without +conversion or special configuration. + +## Structure + +``` +.claude/rules/ +├── README.md ← you are here +├── common/ # Universal principles — 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 +├── bash/ # Shell script-specific +│ ├── coding-style.md +│ └── security.md +└── markdown/ # Markdown authoring conventions + └── conventions.md +``` + +## Rule priority + +Language-specific rules override common rules where they conflict. + +## How to write a new rule + +1. Choose `common/` for language-agnostic principles, or a language directory for + language-specific ones. +2. Use lowercase kebab-case filenames: `coding-style.md`, `testing.md`, `patterns.md`. +3. If extending a common rule, start the file with: + `> This file extends [common/xxx.md](../common/xxx.md) with specific content.` +4. Keep rules actionable (imperatives), concise (< 150 lines per file), and concrete + (include good/bad examples). +5. Update this README when adding new directories. + +## How AI tools consume these rules + +| Tool | Mechanism | +|------|-----------| +| Claude Code | Reads `CLAUDE.md`, then files you reference or load via slash commands | +| 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 | diff --git a/template/.claude/rules/bash/coding-style.md b/template/.claude/rules/bash/coding-style.md new file mode 100644 index 0000000..31b1507 --- /dev/null +++ b/template/.claude/rules/bash/coding-style.md @@ -0,0 +1,79 @@ +# Bash / Shell Coding Style + +# applies-to: **/*.sh + +Shell scripts in this project live under `.claude/hooks/` and `scripts/`. + +## Shebang and strict mode + +```bash +#!/usr/bin/env bash +set -euo pipefail +``` + +For scripts that must not exit non-zero accidentally (e.g. PreToolUse hooks): +```bash +set -uo pipefail # intentionally NOT -e +``` + +## Variable quoting + +Always quote variable expansions: + +```bash +# Correct +if [[ -f "$file_path" ]]; then ... + +# Wrong — breaks on paths with spaces +if [[ -f $file_path ]]; then ... +``` + +Use `[[ ]]` (bash conditionals), not `[ ]` (POSIX test). + +## Reading stdin (hook scripts) + +```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; } +``` + +## Output formatting + +```bash +echo "┌─ Hook name: $context" +echo "│ Informational content" +echo "└─ ✓ Done" +``` + +PostToolUse / Stop / SessionStart: print to **stdout**. +PreToolUse blocking messages: print to **stderr**. + +## Exit codes + +Only PreToolUse hooks should exit 2 (block). All other hooks must exit 0. + +When allowing a PreToolUse tool call to proceed, echo `$INPUT` back to stdout: + +```bash +echo "$INPUT" +exit 0 +``` + +## Functions + +```bash +check_python_file() { + local file_path="$1" + [[ "$file_path" == *.py ]] && [[ -f "$file_path" ]] +} +``` + +## Portability + +Hooks run on macOS and Linux. Avoid GNU-specific flags when a POSIX alternative exists. diff --git a/template/.claude/rules/bash/security.md b/template/.claude/rules/bash/security.md new file mode 100644 index 0000000..908bb7f --- /dev/null +++ b/template/.claude/rules/bash/security.md @@ -0,0 +1,60 @@ +# 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 is a code +injection vector. Use `case` statements instead: + +```bash +case "$action" in + start) start_service ;; + stop) stop_service ;; + *) echo "Unknown action: $action" >&2; exit 1 ;; +esac +``` + +## Validate all inputs + +```bash +file_path="${1:?Usage: script.sh }" + +if [[ "$file_path" == *..* ]]; then + echo "Error: path traversal not allowed" >&2 + exit 1 +fi +``` + +## Secrets in environment + +- Do not echo or log environment variables that may contain secrets. +- Validate required variables exist before use: + ```bash + : "${API_KEY:?API_KEY environment variable is required}" + ``` + +## Temporary files + +```bash +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT +``` + +Never use predictable names like `/tmp/output.txt`. + +## Subprocess calls + +Pass arguments as separate words; never concatenate into a shell string: + +```bash +# Correct +git status --porcelain + +# Wrong — injection risk +sh -c "git $user_command" +``` + +Avoid `curl | bash` patterns. diff --git a/template/.claude/rules/common/code-review.md b/template/.claude/rules/common/code-review.md new file mode 100644 index 0000000..457cb4b --- /dev/null +++ b/template/.claude/rules/common/code-review.md @@ -0,0 +1,44 @@ +# Code Review Standards + +## Self-review checklist + +**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. +- [ ] No commented-out code. + +**Testing** +- [ ] Every new public symbol has at least one test. +- [ ] `just coverage` shows no module below 85 %. +- [ ] Tests are isolated and order-independent. + +**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. +- [ ] User inputs are validated. + +**CI** +- [ ] `just ci` passes locally before pushing. + +## Severity levels + +| Level | Meaning | Action | +|-------|---------|--------| +| CRITICAL | Security vulnerability or data-loss risk | Block merge — must fix | +| HIGH | Bug or significant quality issue | Should fix before merge | +| MEDIUM | Maintainability concern | Consider fixing | +| LOW | Style suggestion | Optional | + +## Automated enforcement + +- **PostToolUse hooks**: ruff + basedpyright after every `.py` edit. +- **Pre-commit hooks**: ruff, basedpyright, secret scan on `git commit`. +- **CI**: full `just ci` on every push and pull request. diff --git a/template/.claude/rules/common/coding-style.md b/template/.claude/rules/common/coding-style.md new file mode 100644 index 0000000..d8d2ba7 --- /dev/null +++ b/template/.claude/rules/common/coding-style.md @@ -0,0 +1,57 @@ +# Coding Style — Common Principles + +These principles apply to all languages in this project. 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` (Python), `snake_case` (Bash). +- 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. +- 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. Mutate in place only when necessary for correctness + or performance. + +## Error handling + +- Never silently swallow exceptions. Either handle them explicitly or propagate. +- Log errors with sufficient context to diagnose the issue without a debugger. +- Do not use bare `except:` blocks unless re-raising immediately. + +## No magic values + +- Replace bare literals (`0`, `""`, `"pending"`) with named constants or enums. + +## No debug artefacts + +- Remove `print()` and temporary debug variables before committing. +- Use the project's logging infrastructure (structlog) instead. + +## Comments + +- Comments explain *why*, not *what*. +- Avoid comments that restate what the code does. +- Use `TODO(username): description` for tracked work items. + +## Line length + +100 characters (enforced by ruff formatter). + +## Imports + +Group and sort imports: standard library → third-party → local. One blank line between groups. diff --git a/template/.claude/rules/common/development-workflow.md b/template/.claude/rules/common/development-workflow.md new file mode 100644 index 0000000..d281c2c --- /dev/null +++ b/template/.claude/rules/common/development-workflow.md @@ -0,0 +1,51 @@ +# Development Workflow + +> Git operations are covered in [git-workflow.md](./git-workflow.md). + +## Before writing any code + +1. Search the codebase for similar patterns before adding new ones. +2. Check third-party libraries (PyPI) before writing utility code. +3. Read the relevant rule files in `.claude/rules/` for the language you are working in. + +## Feature implementation workflow + +### 1. Understand the requirement + +- Read the issue or task thoroughly. +- Identify edge cases upfront and write them down as test cases. + +### 2. Write tests first (TDD) + +See [testing.md](./testing.md). Summary: write a failing test, then write the implementation. + +### 3. Implement + +- Follow the coding style rules for the relevant language. +- Fix PostToolUse hook violations (ruff, basedpyright) after every file edit. + +### 4. Self-review before opening a PR + +```bash +just fix # auto-fix ruff violations +just fmt # format +just lint # 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 85 % +just ci # full pipeline +``` + +## Toolchain 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` | diff --git a/template/.claude/rules/common/git-workflow.md b/template/.claude/rules/common/git-workflow.md new file mode 100644 index 0000000..37dd5e5 --- /dev/null +++ b/template/.claude/rules/common/git-workflow.md @@ -0,0 +1,37 @@ +# Git Workflow + +## Commit message format + +``` +: + + +``` + +**Types**: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `build` + +Rules: +- Subject line ≤ 72 characters; imperative mood ("Add feature", not "Added feature"). +- Reference issues in the footer (`Closes #123`), not the subject. +- One logical change per commit. + +## What never goes in a commit + +- Hardcoded secrets, API keys, passwords, or tokens. +- Generated artefacts reproducible from source (`.pyc`, `.venv/`, `dist/`). +- Merge-conflict markers or `*.rej` files. +- Debug statements (`print()`, `pdb.set_trace()`). + +The `pre-bash-commit-quality.sh` hook scans staged files before every commit. + +## Protected operations + +These commands are blocked by hooks and must not be run without explicit justification: +- `git commit --no-verify` — bypasses quality gates. +- `git push --force` — rewrites shared history. + +## Pull request workflow + +1. Run `just review` (lint + types + docstrings + tests) before opening a PR. +2. Write a PR description explaining the *why*, not just the *what*. +3. All CI checks must be green before requesting review. diff --git a/template/.claude/rules/common/security.md b/template/.claude/rules/common/security.md new file mode 100644 index 0000000..72ec121 --- /dev/null +++ b/template/.claude/rules/common/security.md @@ -0,0 +1,38 @@ +# Security Guidelines + +## Pre-commit checklist + +Before every commit: + +- [ ] No hardcoded secrets (API keys, passwords, tokens, private keys). +- [ ] All user-supplied inputs are validated before use. +- [ ] Parameterised queries used for all database operations. +- [ ] File paths from user input are sanitised (no path traversal). +- [ ] Error messages do not expose internal paths or configuration values. +- [ ] New dependencies are from trusted sources and pinned. + +## Secret management + +```python +# Correct: fail fast with a clear error if the secret is missing +api_key = os.environ["OPENAI_API_KEY"] + +# Wrong: silent fallback masks misconfiguration +api_key = os.environ.get("OPENAI_API_KEY", "") +``` + +Use `python-dotenv` in development. Never commit `.env` files. Document required +variables in `.env.example` with placeholder values. + +## Dependency security + +- Pin all dependency versions in `pyproject.toml` and commit `uv.lock`. +- Review Dependabot / Renovate PRs promptly. + +## Security response protocol + +1. Stop work on the current feature. +2. For critical issues: open a private security advisory, not a public issue. +3. Rotate any exposed secrets before fixing the code. +4. Write a test that reproduces the vulnerability before patching. +5. Search the codebase for similar patterns after fixing. diff --git a/template/.claude/rules/common/testing.md b/template/.claude/rules/common/testing.md new file mode 100644 index 0000000..6c419b9 --- /dev/null +++ b/template/.claude/rules/common/testing.md @@ -0,0 +1,61 @@ +# Testing Requirements + +## Minimum coverage: 85 % + +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 | +|------|-------| +| Unit | Individual functions, pure logic | +| Integration | Multi-module flows, I/O boundaries | + +## Test-Driven Development workflow + +For new features and bug fixes, follow TDD: + +1. Write test — it must **fail** (RED). +2. Write minimal implementation — test must **pass** (GREEN). +3. Refactor for clarity (IMPROVE). +4. Verify coverage did not drop. + +## AAA structure + +```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 +``` + +## Naming + +Use descriptive names that read as sentences: + +``` +test_returns_empty_list_when_no_results_match() +test_raises_value_error_when_email_is_invalid() +``` + +## Test isolation + +- Tests must not share mutable state. +- Mock external I/O at the boundary. +- Tests must pass when run individually and in any order. + +## Running tests + +```bash +just test # all tests, quiet +just test-parallel # parallelised with pytest-xdist +just coverage # coverage report with missing lines +just ci # full pipeline +``` diff --git a/template/.claude/rules/markdown/conventions.md b/template/.claude/rules/markdown/conventions.md new file mode 100644 index 0000000..de26f03 --- /dev/null +++ b/template/.claude/rules/markdown/conventions.md @@ -0,0 +1,51 @@ +# 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 anywhere): +- `README.md` +- `CLAUDE.md` +- `.claude/rules/**/*.md` +- `.github/**/*.md` + +Do **not** create free-standing files such as `ANALYSIS.md` or `NOTES.md` at the +repository root or inside `src/`, `tests/`, or `scripts/`. + +This is enforced by `.claude/hooks/post-edit-markdown.sh`. + +## Headings + +- ATX headings (`#`, `##`, `###`), not Setext underlines. +- One `# Title` per file. +- Do not skip heading levels. +- Sentence-case headings (capitalise first word and proper nouns only). + +## Code blocks + +Always specify the language: +``` +\```python +\```bash +\```yaml +``` + +## Lists + +- Use `-` for unordered lists. +- Use `1.` for all ordered list items. + +## Links + +- Relative links for internal files: `[CLAUDE.md](../CLAUDE.md)`. +- Descriptive link text: `[setup guide](./setup.md)`, not `[click here](./setup.md)`. + +## CLAUDE.md maintenance + +`CLAUDE.md` is the primary context document for AI assistants. Keep it up to date: +- Update when you add or change slash commands, hooks, tooling, or project structure. +- Do not duplicate content already in the rules files — cross-reference with a link. diff --git a/template/.claude/rules/python/coding-style.md.jinja b/template/.claude/rules/python/coding-style.md.jinja new file mode 100644 index 0000000..4bbd291 --- /dev/null +++ b/template/.claude/rules/python/coding-style.md.jinja @@ -0,0 +1,118 @@ +# 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** handles both formatting and linting. Do not use black, isort, or flake8 alongside it. +- 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`, `TCH`, `PGH`, `PT`, `ARG`, + `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 +def calculate_discount(price, tier): ... +``` + +- **basedpyright** strict mode for type checking (`just type`). +- Prefer `X | Y` union syntax over `Optional[X]` / `Union[X, Y]`. + +## 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. + """ +``` + +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` | +| Private | leading `_` | `_internal_helper()` | + +## Immutability + +```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 + +# Wrong: silently swallowed +try: + result = parse_config(path) +except Exception: + result = None +``` + +## Logging + +Use **structlog** (configured via `{{ package_name }}.common.logging_manager`). +Never use `print()` or `logging.getLogger()` in application code. + +```python +import structlog +log = structlog.get_logger() + +log.info("user_created", user_id=user.id) +log.error("payment_failed", order_id=order_id, reason=str(exc), llm=True) +``` + +## Imports + +```python +# stdlib → third-party → local +import os +from pathlib import Path + +import structlog + +from {{ package_name }}.common.utils import slugify +``` diff --git a/template/.claude/rules/python/hooks.md b/template/.claude/rules/python/hooks.md new file mode 100644 index 0000000..de7f1c9 --- /dev/null +++ b/template/.claude/rules/python/hooks.md @@ -0,0 +1,64 @@ +# 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 + +| Hook | Trigger | What it does | +|------|---------|--------------| +| `post-edit-python.sh` | Edit or Write on `*.py` | Runs `ruff check` + `basedpyright` on the saved file | + +### What the hook checks + +1. **ruff check** — all active rule sets including `D` (docstrings), `C90` (complexity), + `PERF` (performance anti-patterns). +2. **basedpyright** — type correctness in strict mode. + +Example output: + +``` +┌─ 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 violations before moving to the next file. + +## Pre-commit hooks for Python + +| 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 files for secrets and debug statements | + +The `pre-bash-block-no-verify.sh` hook prevents `git commit --no-verify`. + +## `print()` warning + +`print()` in `src/` is a ruff violation (`T201`). Use structlog: + +```python +# Wrong +print(f"Processing order {order_id}") + +# Correct +log = structlog.get_logger() +log.info("processing_order", order_id=order_id) +``` + +`print()` is permitted in `scripts/` and test files. + +## Type-checking configuration + +basedpyright settings in `pyproject.toml` under `[tool.basedpyright]`. Do not +weaken `typeCheckingMode` or add broad `# type: ignore` without a specific error +code and explanation. diff --git a/template/.claude/rules/python/patterns.md.jinja b/template/.claude/rules/python/patterns.md.jinja new file mode 100644 index 0000000..495ec98 --- /dev/null +++ b/template/.claude/rules/python/patterns.md.jinja @@ -0,0 +1,89 @@ +# Python Patterns + +# applies-to: **/*.py, **/*.pyi + +> This file extends [common/patterns.md](../common/patterns.md) with Python-specific content. + +## Dataclasses as DTOs + +```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 + +```python +from typing import Protocol + +class Repository(Protocol): + def find_by_id(self, id: int) -> dict | None: ... + def save(self, entity: dict) -> dict: ... +``` + +## Context managers + +```python +from contextlib import contextmanager + +@contextmanager +def managed_connection(url: str): + conn = connect(url) + try: + yield conn + finally: + conn.close() +``` + +## Dependency injection + +Pass dependencies as constructor arguments; avoid module-level singletons: + +```python +class OrderService: + def __init__(self, repo: Repository) -> None: + self._repo = repo +``` + +## Configuration objects + +```python +@dataclass(frozen=True) +class AppConfig: + database_url: str + log_level: str = "INFO" + + @classmethod + def from_env(cls) -> "AppConfig": + return cls(database_url=os.environ["DATABASE_URL"]) +``` + +## Exception hierarchy + +```python +class {{ package_name | title | replace("_", "") }}Error(Exception): + """Base class for all application errors.""" + +class ConfigError({{ package_name | title | replace("_", "") }}Error): + """Raised when configuration is missing or invalid.""" +``` + +## structlog context binding + +```python +from {{ package_name }}.common.logging_manager import bind_context, clear_context + +bind_context(request_id="req-abc", user_id=42) +# ... all logs in this scope carry these fields +clear_context() +``` diff --git a/template/.claude/rules/python/security.md b/template/.claude/rules/python/security.md new file mode 100644 index 0000000..580449f --- /dev/null +++ b/template/.claude/rules/python/security.md @@ -0,0 +1,70 @@ +# Python Security + +# applies-to: **/*.py, **/*.pyi + +> This file extends [common/security.md](../common/security.md) with Python-specific content. + +## Secret management + +```python +import os + +# Correct: fails immediately with a clear error +api_key = os.environ["OPENAI_API_KEY"] + +# Wrong: silent fallback masks misconfiguration +api_key = os.environ.get("OPENAI_API_KEY", "") +``` + +Use `python-dotenv` for development. Never commit `.env` files. + +## Input validation + +Validate all external inputs at the boundary: + +```python +def process_order(order_id: str) -> Order: + if not order_id.isalnum(): + raise ValueError(f"Invalid order ID: {order_id!r}") +``` + +## SQL injection prevention + +```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 + +```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") +``` + +## Subprocess calls + +```python +# Correct: list form, no shell expansion +result = subprocess.run(["git", "status"], capture_output=True, check=True) + +# Wrong: shell=True + user input = injection risk +result = subprocess.run(f"git {user_cmd}", shell=True, check=True) +``` + +## Tokens and random values + +```python +import secrets +token = secrets.token_urlsafe(32) # cryptographically secure +``` + +Never use `random` for security-sensitive values. diff --git a/template/.claude/rules/python/testing.md b/template/.claude/rules/python/testing.md new file mode 100644 index 0000000..541d036 --- /dev/null +++ b/template/.claude/rules/python/testing.md @@ -0,0 +1,80 @@ +# 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. + +## Directory layout + +``` +tests/ +├── conftest.py +├── test_core.py +└── / + └── test_module.py +``` + +Files: `test_.py`. Functions: start with `test_`. + +## Running tests + +```bash +just test # pytest -q +just test-parallel # pytest -q -n auto +just coverage # pytest --cov=src --cov-report=term-missing +``` + +## Fixtures + +Define shared fixtures in `conftest.py`. Use `tmp_path` for temporary files: + +```python +import pytest +from mypackage.core import AppContext + +@pytest.fixture() +def app_context() -> AppContext: + ctx = AppContext() + yield ctx + ctx.close() +``` + +## Parametrised tests + +```python +@pytest.mark.parametrize(("input_val", "expected"), [ + ("hello world", "hello-world"), + ("UPPER", "upper"), +]) +def test_slugify(input_val: str, expected: str) -> None: + assert slugify(input_val) == expected +``` + +## Mocking + +Mock at the boundary — mock I/O calls, not internal helpers: + +```python +def test_fetch_user_returns_none_when_not_found(mocker): + mocker.patch("mypackage.db.execute", return_value=[]) + result = fetch_user(user_id=99) + assert result is None +``` + +## Exception testing + +```python +def test_raises_on_invalid_email(): + with pytest.raises(ValueError, match="invalid email"): + validate_email("not-an-email") +``` + +## Coverage + +- Overall threshold: ≥ 85 %. +- Coverage measured on `src/` only; test files excluded. +- Configuration in `pyproject.toml` under `[tool.coverage.*]`. diff --git a/template/CLAUDE.md.jinja b/template/CLAUDE.md.jinja index 9ede502..04d22c2 100644 --- a/template/CLAUDE.md.jinja +++ b/template/CLAUDE.md.jinja @@ -56,6 +56,20 @@ Do not manually change the answers file (`.copier-answers.yml`, controlled by `_ Pre-commit here covers both cases. Prefer SSH or credential-free template URLs so secrets do not land in `.copier-answers.yml`. +## AI rules + +Detailed coding standards are documented as plain Markdown files under `.claude/rules/` +and are readable by any AI assistant (Claude Code, Cursor, or any LLM): + +``` +.claude/rules/ +├── README.md ← how to read and write rules +├── common/ ← language-agnostic: coding-style, git-workflow, testing, security, … +├── python/ ← Python: coding-style, testing, patterns, security, hooks +├── bash/ ← Bash: coding-style, security +└── markdown/ ← placement rules, authoring conventions +``` + ## Standards enforcement Standards are enforced at three layers — during development via Claude hooks, at commit via pre-commit, and in CI via GitHub Actions.