|
| 1 | +# Dogfood Pre-commit Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Wire pre-commit into the maintainer repo as a local-only gate that mirrors the guardrail the template ships, adapted to the maintainer's surface. |
| 6 | + |
| 7 | +**Architecture:** Add `pre-commit` as a dev dependency, a root `.pre-commit-config.yaml` (hygiene hooks + local `uv run ruff`/`basedpyright` hooks + `forbid-rej`), `just setup`/`just precommit` recipes, and an AGENTS.md section. No CI job and no `test_generation.py` change — the substantive checks are already CI-enforced by the existing `lint`/`typecheck` jobs; hygiene is a local gate. Verification per task is running the relevant command and observing its output (there is no pytest surface for this layer). |
| 8 | + |
| 9 | +**Tech Stack:** pre-commit (>=4,<5), uv, just, ruff (locked 0.15.19), basedpyright (1.39.8), `pre-commit/pre-commit-hooks` v6.0.0. |
| 10 | + |
| 11 | +**Source spec:** `docs/superpowers/specs/2026-07-01-dogfood-precommit-design.md` |
| 12 | + |
| 13 | +## Global Constraints |
| 14 | + |
| 15 | +- Branch: work on `chore/dogfood-precommit` (already contains the spec commit `2cbacac`). Do not branch again. |
| 16 | +- `pre-commit>=4,<5` — mirrors `minimum_pre_commit_version: "4.0.0"`. |
| 17 | +- SHA-pin the `pre-commit-hooks` repo with its exact-tag comment: `rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0` (AGENTS.md NEVER rule: never change a pinned SHA without updating its exact-tag comment). |
| 18 | +- Hygiene hooks (`end-of-file-fixer`, `trailing-whitespace`) carry `exclude: '^template/'` — `template/` is Jinja and its whitespace is deliberate (`keep_trailing_newline: true`). |
| 19 | +- Local ruff hooks use `uv run ruff` (locked 0.15.19), NOT `astral-sh/ruff-pre-commit`. |
| 20 | +- No pytest hook; no CI pre-commit job; no `test_generation.py` change; no CHANGELOG `[Unreleased]` entry (CHANGELOG is downstream/template-scoped). |
| 21 | +- Commits: Conventional Commit format, no AI-attribution trailer, GPG-signed (automatic in this repo). Author is `Ashlen <[email protected]>`. |
| 22 | +- Every edited/added file outside `template/` must be hygiene-clean (no trailing whitespace, ends in a final newline) — the pre-commit hooks enforce this on the repo itself once installed. |
| 23 | +- First `pre-commit run` downloads the `pre-commit-hooks` environment at the pinned SHA (needs network); subsequent runs are cached. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +### Task 1: Add pre-commit dev dependency |
| 28 | + |
| 29 | +**Files:** |
| 30 | +- Modify: `pyproject.toml` (the `[dependency-groups] dev` array) |
| 31 | +- Modify: `uv.lock` (regenerated) |
| 32 | + |
| 33 | +**Interfaces:** |
| 34 | +- Consumes: nothing. |
| 35 | +- Produces: `uv run pre-commit` becomes available for all later tasks. |
| 36 | + |
| 37 | +- [ ] **Step 1: Add the dependency** |
| 38 | + |
| 39 | +In `pyproject.toml`, add `"pre-commit>=4,<5",` to the `[dependency-groups] dev` array, alongside the existing dev dependencies. Keep the array's existing ordering/style. |
| 40 | + |
| 41 | +- [ ] **Step 2: Lock and sync** |
| 42 | + |
| 43 | +Run: `uv lock && uv sync` |
| 44 | +Expected: `uv.lock` updates to include `pre-commit` (and its deps: `cfgv`, `identify`, `nodeenv`, `virtualenv`, etc.); `uv sync` installs them into `.venv`. |
| 45 | + |
| 46 | +- [ ] **Step 3: Verify pre-commit is runnable at the required major** |
| 47 | + |
| 48 | +Run: `uv run pre-commit --version` |
| 49 | +Expected: prints `pre-commit 4.x.y` (major 4). |
| 50 | + |
| 51 | +- [ ] **Step 4: Verify the lock recorded pre-commit** |
| 52 | + |
| 53 | +Run: `grep -A1 'name = "pre-commit"' uv.lock | head -2` |
| 54 | +Expected: shows `name = "pre-commit"` and a `version = "4...."` line. |
| 55 | + |
| 56 | +- [ ] **Step 5: Commit** |
| 57 | + |
| 58 | +```bash |
| 59 | +git add pyproject.toml uv.lock |
| 60 | +git commit -m "build(deps): add pre-commit dev dependency" |
| 61 | +``` |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +### Task 2: Add the root pre-commit config |
| 66 | + |
| 67 | +**Files:** |
| 68 | +- Create: `.pre-commit-config.yaml` |
| 69 | + |
| 70 | +**Interfaces:** |
| 71 | +- Consumes: `uv run pre-commit` (Task 1); `uv run ruff`, `uv run basedpyright` (already present). |
| 72 | +- Produces: a validated pre-commit config that later recipes/tasks invoke. |
| 73 | + |
| 74 | +- [ ] **Step 1: Create `.pre-commit-config.yaml`** |
| 75 | + |
| 76 | +```yaml |
| 77 | +minimum_pre_commit_version: "4.0.0" |
| 78 | +default_install_hook_types: [pre-commit, pre-push] |
| 79 | +default_stages: [pre-commit] |
| 80 | + |
| 81 | +repos: |
| 82 | + - repo: https://github.com/pre-commit/pre-commit-hooks |
| 83 | + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 |
| 84 | + hooks: |
| 85 | + - id: check-merge-conflict |
| 86 | + args: [--assume-in-merge] |
| 87 | + - id: end-of-file-fixer |
| 88 | + exclude: '^template/' |
| 89 | + - id: trailing-whitespace |
| 90 | + exclude: '^template/' |
| 91 | + |
| 92 | + - repo: local |
| 93 | + hooks: |
| 94 | + - id: ruff-check |
| 95 | + name: ruff check (--fix) |
| 96 | + entry: uv run ruff check --fix --force-exclude |
| 97 | + language: system |
| 98 | + types_or: [python, pyi] |
| 99 | + require_serial: true |
| 100 | + - id: ruff-format |
| 101 | + name: ruff format |
| 102 | + entry: uv run ruff format --force-exclude |
| 103 | + language: system |
| 104 | + types_or: [python, pyi] |
| 105 | + require_serial: true |
| 106 | + - id: basedpyright |
| 107 | + name: basedpyright (type check) |
| 108 | + entry: uv run basedpyright |
| 109 | + language: system |
| 110 | + types: [python] |
| 111 | + pass_filenames: false |
| 112 | + stages: [pre-push] |
| 113 | + - id: forbid-rej |
| 114 | + name: forbid copier .rej conflict files |
| 115 | + entry: "unresolved copier .rej conflict files present; resolve and delete them" |
| 116 | + language: fail |
| 117 | + files: '\.rej$' |
| 118 | +``` |
| 119 | +
|
| 120 | +- [ ] **Step 2: Validate the config parses** |
| 121 | +
|
| 122 | +Run: `uv run pre-commit validate-config .pre-commit-config.yaml` |
| 123 | +Expected: no output, exit code 0 (invalid YAML/schema would print an error). |
| 124 | + |
| 125 | +- [ ] **Step 3: Stage the new file so `--all-files` sees it, then run commit-stage hooks** |
| 126 | + |
| 127 | +`--all-files` operates on `git ls-files`; an unstaged new file is invisible. |
| 128 | + |
| 129 | +Run: `git add .pre-commit-config.yaml && uv run pre-commit run --all-files` |
| 130 | +Expected: `check-merge-conflict`, `end-of-file-fixer`, `trailing-whitespace`, `ruff-check`, `ruff-format` each print `Passed` (the tree is already clean, so the fixer hooks make no changes and exit 0); `forbid-rej` prints `(no files to check) Skipped` — a `language: fail` hook only runs (and can only ever fail) when a matching `.rej` file is present. First run also prints `[INFO] Initializing environment...` lines while it fetches the pinned hooks repo. |
| 131 | + |
| 132 | +- [ ] **Step 4: Run the pre-push stage hook** |
| 133 | + |
| 134 | +Run: `uv run pre-commit run --all-files --hook-stage pre-push` |
| 135 | +Expected: `end-of-file-fixer`, `trailing-whitespace`, and `basedpyright` all print `Passed` — the two fixers inherit a `pre-push` stage from the pinned pre-commit-hooks v6.0.0 manifest (which overrides `default_stages`), so they run here too. `check-merge-conflict`, `ruff-check`, `ruff-format`, and `forbid-rej` are stage-filtered out at pre-push. |
| 136 | + |
| 137 | +- [ ] **Step 5: Confirm no files were modified** |
| 138 | + |
| 139 | +Run: `git status --short` |
| 140 | +Expected: only `A .pre-commit-config.yaml` staged; no unexpected modifications from the fixer hooks. |
| 141 | + |
| 142 | +- [ ] **Step 6: Prove `forbid-rej` has teeth (negative check)** |
| 143 | + |
| 144 | +Every other step exercises only the green path; confirm the one un-CI-backstopped, repo-authored hook can actually fail. |
| 145 | + |
| 146 | +Run: `printf 'x\n' > scratch.rej && uv run pre-commit run forbid-rej --files scratch.rej; rm -f scratch.rej` |
| 147 | +Expected: `forbid-rej` reports `Failed` (non-zero) with the "unresolved copier .rej conflict files present" message, proving the gate has teeth. Use `--files scratch.rej`, NOT `git add -N` + `--all-files`: `*.rej` is gitignored, so an intent-to-add path would never feed the file to the hook and would give a false pass. |
| 148 | + |
| 149 | +- [ ] **Step 7: Commit** |
| 150 | + |
| 151 | +```bash |
| 152 | +git add .pre-commit-config.yaml |
| 153 | +git commit -m "chore(pre-commit): add local pre-commit config" |
| 154 | +``` |
| 155 | + |
| 156 | +--- |
| 157 | + |
| 158 | +### Task 3: Add `just setup` and `just precommit` recipes |
| 159 | + |
| 160 | +**Files:** |
| 161 | +- Modify: `justfile` |
| 162 | + |
| 163 | +**Interfaces:** |
| 164 | +- Consumes: `.pre-commit-config.yaml` (Task 2). |
| 165 | +- Produces: `just setup` (installs the git hooks) and `just precommit` (runs both hook stages). |
| 166 | + |
| 167 | +- [ ] **Step 1: Add the recipes to `justfile`** |
| 168 | + |
| 169 | +Add, after the existing `fmt-check` recipe: |
| 170 | + |
| 171 | +```just |
| 172 | +
|
| 173 | +# one-time: sync the venv and install the git hooks (maintainer is not copier-generated) |
| 174 | +setup: |
| 175 | + uv sync |
| 176 | + uv run pre-commit install |
| 177 | +
|
| 178 | +# run every hook over the whole tree: commit-stage hooks, then pre-push basedpyright |
| 179 | +precommit: |
| 180 | + uv run pre-commit run --all-files |
| 181 | + uv run pre-commit run --all-files --hook-stage pre-push |
| 182 | +``` |
| 183 | + |
| 184 | +- [ ] **Step 2: Verify the recipes are listed** |
| 185 | + |
| 186 | +Run: `just --list` |
| 187 | +Expected: `setup` and `precommit` appear among the recipes with their doc comments. |
| 188 | + |
| 189 | +- [ ] **Step 3: Install the git hooks** |
| 190 | + |
| 191 | +Run: `just setup` |
| 192 | +Expected: `uv sync` reports up-to-date (or a no-op resolve); `pre-commit install` prints `pre-commit installed at .git/hooks/pre-commit` and `pre-commit installed at .git/hooks/pre-push`. |
| 193 | + |
| 194 | +- [ ] **Step 4: Verify both git hooks were written** |
| 195 | + |
| 196 | +Run: `test -f "$(git rev-parse --git-path hooks/pre-commit)" && test -f "$(git rev-parse --git-path hooks/pre-push)" && echo OK` |
| 197 | +Expected: prints `OK` (both hook files exist). |
| 198 | + |
| 199 | +Note: run this plan from a normal clone, not a linked git worktree — `just setup` inside a worktree installs the hooks into the shared common `.git/hooks/`, mutating the main checkout. |
| 200 | + |
| 201 | +- [ ] **Step 5: Run the full local gate via the recipe** |
| 202 | + |
| 203 | +Run: `just precommit` |
| 204 | +Expected: line 1 (commit stage) shows `check-merge-conflict`, `end-of-file-fixer`, `trailing-whitespace`, `ruff-check`, `ruff-format` `Passed` and `forbid-rej` `(no files to check) Skipped`; line 2 (pre-push) shows `end-of-file-fixer`, `trailing-whitespace`, and `basedpyright` `Passed`; recipe exits 0. |
| 205 | + |
| 206 | +- [ ] **Step 6: Commit** |
| 207 | + |
| 208 | +Note: the git hooks are now installed, so this commit triggers the commit-stage hooks — they should pass on a clean tree (this is the dogfooding working). |
| 209 | + |
| 210 | +```bash |
| 211 | +git add justfile |
| 212 | +git commit -m "chore: add just setup and precommit recipes" |
| 213 | +``` |
| 214 | + |
| 215 | +--- |
| 216 | + |
| 217 | +### Task 4: Document pre-commit in AGENTS.md |
| 218 | + |
| 219 | +**Files:** |
| 220 | +- Modify: `AGENTS.md` (insert a new `## Pre-commit` section immediately after the "Lint & format this repo" section, before "Add a guardrail layer") |
| 221 | + |
| 222 | +**Interfaces:** |
| 223 | +- Consumes: the recipes (Task 3) and config (Task 2) that the section describes. |
| 224 | +- Produces: nothing downstream. |
| 225 | + |
| 226 | +- [ ] **Step 1: Insert the `## Pre-commit` section** |
| 227 | + |
| 228 | +Insert immediately after the "Lint & format this repo" section's final paragraph and before `## Add a guardrail layer`: |
| 229 | + |
| 230 | +````markdown |
| 231 | +## Pre-commit |
| 232 | + |
| 233 | +```bash |
| 234 | +just setup # one-time: sync the venv and install the git hooks |
| 235 | +just precommit # run every hook (commit-stage + pre-push basedpyright) over the whole tree |
| 236 | +``` |
| 237 | + |
| 238 | +`pre-commit install` registers both git hooks (`default_install_hook_types: [pre-commit, pre-push]`). On commit: ruff-check `--fix`, ruff-format, end-of-file-fixer / trailing-whitespace (both `exclude: '^template/'` — template Jinja whitespace is deliberate), check-merge-conflict, forbid-rej. On push: end-of-file-fixer and trailing-whitespace also run (they inherit a `pre-push` stage from the pinned `pre-commit-hooks` manifest, on any changed non-`template/` text file), plus basedpyright when the push includes a `*.py` file. |
| 239 | + |
| 240 | +This is a **local-only** gate: there is no pre-commit CI job, matching the template (whose downstream CI also never runs pre-commit). The ruff and basedpyright *substance* is enforced by the existing `lint` and `typecheck` CI jobs; the hygiene hooks (eof / trailing-whitespace / check-merge-conflict / forbid-rej) have **no** CI backstop and are a local convenience here. A bare `uv run pre-commit run --all-files` runs commit-stage hooks only — basedpyright fires on push or via `just typecheck`; `just precommit` runs both. |
| 241 | + |
| 242 | +Deliberate divergences from `template/.pre-commit-config.yaml.jinja`: ruff runs via local `uv run ruff` hooks (locked 0.15.19) instead of the `astral-sh/ruff-pre-commit` repo (pinned 0.15.18) — the venv is always synced here, so there is no bootstrap reason to keep the isolated-env repo hook; the pytest hook is dropped (the maintainer's only suite is the heavy generation matrix — CI-only). The two configs share the SHA-pinned `pre-commit-hooks` block: **bump both `rev:` pins together** (v6.0.0 = `3e8a8703…`). |
| 243 | +```` |
| 244 | + |
| 245 | +- [ ] **Step 2: Verify the section is present and placed correctly** |
| 246 | + |
| 247 | +Run: `grep -nE '^## ' AGENTS.md` |
| 248 | +Expected: `## Pre-commit` appears between `## Lint & format this repo` and `## Add a guardrail layer`. |
| 249 | + |
| 250 | +- [ ] **Step 3: Verify AGENTS.md passes the hygiene hooks (it is not under template/, so it is checked)** |
| 251 | + |
| 252 | +Run: `git add AGENTS.md && uv run pre-commit run --files AGENTS.md` |
| 253 | +Expected: the three hygiene hooks (`trailing-whitespace`, `end-of-file-fixer`, `check-merge-conflict`) print `Passed`; `ruff-check`, `ruff-format`, and `forbid-rej` report `(no files to check) Skipped`; `basedpyright` does not appear (it is pre-push-only, while `--files` runs the commit stage). |
| 254 | + |
| 255 | +- [ ] **Step 4: Commit** |
| 256 | + |
| 257 | +```bash |
| 258 | +git add AGENTS.md |
| 259 | +git commit -m "docs(agents): document pre-commit workflow" |
| 260 | +``` |
| 261 | + |
| 262 | +--- |
| 263 | + |
| 264 | +### Task 5: Final acceptance |
| 265 | + |
| 266 | +**Files:** none (verification only). |
| 267 | + |
| 268 | +- [ ] **Step 1: Full gate green from a clean tree** |
| 269 | + |
| 270 | +Run: `just precommit` |
| 271 | +Expected: commit-stage line — five hooks `Passed` and `forbid-rej` `Skipped`; pre-push line — `end-of-file-fixer`, `trailing-whitespace`, `basedpyright` `Passed`; exit 0. |
| 272 | + |
| 273 | +- [ ] **Step 2: Confirm working tree is clean (no hook left an unstaged fix)** |
| 274 | + |
| 275 | +Run: `git status --short` |
| 276 | +Expected: empty output. |
| 277 | + |
| 278 | +- [ ] **Step 3: Confirm all commits are present on the branch** |
| 279 | + |
| 280 | +Run: `git log --oneline main..HEAD` |
| 281 | +Expected: six commits — the spec commit, the plan commit, and the four implementation commits (`build(deps)…`, `chore(pre-commit)…`, `chore: add just setup…`, `docs(agents)…`). |
| 282 | + |
| 283 | +- [ ] **Step 4: Push and open the PR** (only when instructed by the operator) |
| 284 | + |
| 285 | +Push the branch and open a PR with the pr-descriptions skill. The PR contains the spec, this plan, and the pre-commit wiring; note in the body that it is a local-only gate with no CI change, and reference gap #1 of the dogfooding audit. Also add a body line flagging the tracked follow-up: reconcile the template's shipped `ruff-pre-commit` v0.15.18 pin with the locked ruff 0.15.19 at the source (see the spec's Out-of-scope section). |
0 commit comments