# Pre-commit hooks — aligned with `.github/workflows/ci.yml` lint job: # ruff check + ruff format on the public CI source scope from # `infrastructure.project.public_scope` # mypy on the same paths (see `[tool.mypy]` in pyproject.toml) # # Pre-commit: staged-index secret scanning plus the lint/type gates below. # # Pre-push (fast): tracked-secret and no-mocks scripts + pytest on # `tests/infra_tests/git_hook_smoke/`, Bandit MEDIUM+ (`bandit.yaml`, including # `exclude_dirs` for archive/WIP roots and local `.venv` / `site-packages` # trees), and a stale-manifest check via `infrastructure.skills check`. Full # infra tests run in CI (`test-infra`); collecting all of `tests/infra_tests/` # here is too slow for every push (~thousands of tests). # # Manual: `pre-commit run --all-files --hook-stage manual` (also runs the # strict `bandit-low` pass, which mirrors the LOW-severity sweep allowed by # `bandit.yaml`). # # PATH robustness: GUI git clients (GitHub Desktop, IDEs) launch hooks with a # minimal login PATH that does NOT include `~/.local/bin` (uv's default install # dir), so a bare `uv`/`uvx` would exit 127 "command not found". Every hook # script below therefore prepends the common uv install locations to PATH # before invoking uv/uvx so commits succeed from a terminal AND from GUI # clients identically. repos: # Standard hygiene hooks — fast (<1s each), catch entire classes of issues # before push that ruff/mypy don't cover (non-Python files, binary blobs, # merge-conflict markers, trailing whitespace, missing EOF newlines). - repo: https://github.com/pre-commit/pre-commit-hooks rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # v5.0.0 hooks: - id: check-added-large-files name: check-added-large-files (prevent binary blobs) args: ["--maxkb=512"] exclude: | (?x)^( projects/templates/.*\.png| projects/templates/.*\.svg| projects/templates/.*\.pdf| projects/templates/.*\.epub| output/templates/.*\.png| output/templates/.*\.svg| output/templates/.*\.pdf| output/templates/.*\.epub| projects/templates/template_literature_meta_analysis/output/fulltext/fulltext_inventory\.json ) - id: check-merge-conflict name: check-merge-conflict (catch conflict markers) - id: trailing-whitespace name: trailing-whitespace (strip trailing whitespace) exclude: | (?x)^( output/.*| projects/templates/[^/]+/output/.*| projects/templates/.*\.svg ) - id: end-of-file-fixer name: end-of-file-fixer (ensure trailing newline) exclude: | (?x)^( output/.*| projects/templates/[^/]+/output/.*| projects/templates/.*\.svg| projects/templates/.*\.png ) - id: check-yaml name: check-yaml (validate YAML syntax) args: ["--allow-multiple-documents"] - id: check-toml name: check-toml (validate TOML syntax) - id: check-json name: check-json (validate JSON syntax) exclude: | (?x)^( .*/\.vscode/.*| .*/\.claude/.* ) - repo: local hooks: - id: staged-secret-scan name: staged-index secret scan (A/C/M/R) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && export PYTHONDONTWRITEBYTECODE=1 && uv run python scripts/audit/check_staged_secrets.py language: system pass_filenames: false stages: [pre-commit, manual] always_run: true verbose: true - id: ruff-ci name: ruff check + format (CI scope) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && CI_LINT_PATHS="$(uv run python -m infrastructure.project.public_scope lint-paths)" && uv run ruff check $CI_LINT_PATHS --fix && uv run ruff format $CI_LINT_PATHS language: system pass_filenames: false stages: [pre-commit, manual] always_run: true verbose: true - id: mypy-ci name: mypy (CI scope) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && CI_SOURCE_PATHS="$(uv run python -m infrastructure.project.public_scope source-paths)" && uv run python scripts/gates/mypy_ratchet.py $CI_SOURCE_PATHS language: system pass_filenames: false stages: [pre-commit, manual] always_run: true verbose: true - id: pre-push-quick name: generated-artifact guard + strict no-stand-ins policy + hook smoke pytest entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && export PYTHONDONTWRITEBYTECODE=1 && uv run python scripts/audit/check_tracked_generated_artifacts.py && uv run python scripts/audit/check_tracked_secrets.py && uv run python scripts/audit/check_tracked_all.py && uv run python scripts/audit/verify_no_mocks.py && uv run python scripts/audit/verify_no_mocks.py --inventory --max-dependency-replacements 0 && uv run pytest tests/infra_tests/git_hook_smoke/ -q --maxfail=1 --import-mode=importlib language: system pass_filenames: false stages: [pre-push, manual] always_run: true verbose: true # Docs/signpost contracts at push time. The personal-memory leak class # shipped to origin once (commit 5345b025) because these guards only ran # in the test suite, not before push: (1) check_template_drift --strict # fails on any dead exemplar link (same gate as CI docs-lint); (2) the # public AGENTS.md must never carry "## Learned User Preferences" / # "## Learned Workspace Facts" personal-memory blocks. - id: docs-contract-guard name: docs signpost + public-AGENTS contract (dead links + no personal memory) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && export PYTHONDONTWRITEBYTECODE=1 && uv run python scripts/audit/check_template_drift.py --strict && uv run python scripts/docgen/api_reference.py --check && uv run python scripts/docgen/exemplar_roster.py --check && uv run python scripts/docgen/counts.py --check && uv run python scripts/docgen/publication_records.py --check && uv run pytest tests/infra_tests/test_docs_discovery_consistency.py::test_root_agents_is_public_repo_contract_not_personal_memory -q --import-mode=importlib language: system pass_filenames: false stages: [pre-push, manual] always_run: true verbose: true # Mirrors the CI `security` job's Bandit step # (.github/workflows/ci.yml — "Code security scan (Bandit MEDIUM+ severity)"). # Same scope and severity gate (`-ll` = MEDIUM+); exclusions are configured # in `bandit.yaml` (`exclude_dirs`). Fails the push when a new MEDIUM/HIGH # finding lands so contributors hear it locally before CI does. # `-c bandit.yaml` applies the documented LOW-severity allow-list policy. - id: bandit-quick name: bandit MEDIUM+ (CI scope) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && uv run bandit -c bandit.yaml -r -ll infrastructure/ scripts/ projects/ -q language: system pass_filenames: false stages: [pre-push, manual] always_run: true verbose: true # Catches drift in `.cursor/skill_manifest.json` vs the on-disk skill # tree. Cheap (~0.2 s); runs before every push so a stale manifest never # rides into main. - id: skills-check name: infrastructure.skills check (manifest freshness) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && uv run python -m infrastructure.skills check language: system pass_filenames: false stages: [pre-push, manual] always_run: true verbose: true # Verifies .cursor/operations_manifest.json (the machine-readable catalog # of every `python -m infrastructure.X` CLI) matches live AST discovery — # the operations counterpart to skills-check. Regenerate with # `uv run python -m infrastructure.skills operations-write`. - id: operations-check name: infrastructure.skills operations-check (operation manifest freshness) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && uv run python -m infrastructure.skills operations-check language: system pass_filenames: false stages: [pre-push, manual] always_run: true verbose: true # Audits every public Python module under `infrastructure/` for missing # `__all__` on re-exporting umbrella modules. Prevents regression of the # `[attr-defined]` mypy class of bug — see docs/rules/api_design.md. # Cheap (~0.2 s); runs before every push and on the lint hook stage. - id: all-exports-check name: infrastructure.skills check-all-exports entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && uv run python -m infrastructure.skills check-all-exports language: system pass_filenames: false stages: [pre-push, manual] always_run: true verbose: true # Skill-reachability gate (mirrors the CI docs-lint step). Asserts the # docs front door (`docs/AGENTS.md`) carries resolving links to all three # discovery entrypoints (`docs/_generated/skills_index.md`, # `docs/prompts/SKILL.md`, `docs/prompts/COMPOSITION.md`) and that every # discovered `SKILL.md` appears in the generated skills index. Cheap # (~0.3 s); runs on the lint hook stage so index drift is caught locally. - id: skill-reachability-check name: skill-reachability gate (front-door links + index completeness) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && uv run python scripts/gates/skill_reachability_check.py language: system pass_filenames: false stages: [pre-commit, pre-push, manual] always_run: true verbose: true # Strict pass: LOW + MEDIUM + HIGH against `bandit.yaml`'s allow-list. # Manual-stage only (run with `pre-commit run --hook-stage manual # bandit-low`) — too noisy for every push, but kept here so the # invocation stays in lockstep with the documented policy. - id: bandit-low name: bandit LOW (allow-listed via bandit.yaml) entry: bash args: - -eo - pipefail - -c - >- export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" && uv run bandit -c bandit.yaml -r --severity-level low infrastructure/ scripts/ -q language: system pass_filenames: false stages: [manual] always_run: true verbose: true