From 09574751b5aaf3f4ab36e986e210ec86589cacd5 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 18:20:07 -0600 Subject: [PATCH 1/6] docs(specs): add dogfood pre-commit design Reviewed design for wiring pre-commit into the maintainer repo (gap #1 of the dogfooding-gap audit): a local-only mirror of the shipped config, with ruff run via local `uv run ruff` hooks (locked 0.15.19, no ruff-pre-commit drift), the pytest hook dropped (CI matrix is the enforcer), and hygiene hooks excluding template/. Folds in the adversarial review's amendments (a `setup` bootstrap recipe, a two-stage `precommit` recipe, and the AGENTS.md section). --- .../2026-07-01-dogfood-precommit-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-dogfood-precommit-design.md diff --git a/docs/superpowers/specs/2026-07-01-dogfood-precommit-design.md b/docs/superpowers/specs/2026-07-01-dogfood-precommit-design.md new file mode 100644 index 0000000..6da3c2d --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-dogfood-precommit-design.md @@ -0,0 +1,204 @@ +# Dogfood pre-commit — design (2026-07-01) + +## Goal + +Run the same *class* of pre-commit gate the template ships on the maintainer +repo itself, adapted to the maintainer's actual surface (`tests/` only, a slow +generation-matrix suite, ruff pinned via `uv.lock`), green from the first +commit. This closes gap #1 of the dogfooding-gap audit +(`docs/superpowers/plans/2026-07-01-dogfood-gap-audit.md`). + +This design was adversarially reviewed before writing; the config is unchanged +from the reviewed proposal and the review's amendments are folded into the +wiring and documentation below. Zero blockers; the tracked tree starts green +(no trailing-whitespace / missing-final-newline / conflict-marker / `.rej` +findings today). + +## Decisions + +Three forks were resolved with the maintainer: + +1. **pytest hook: dropped.** The template's pytest hook runs + `pytest -m "not property" tests/unit`, but the maintainer has no + `tests/unit`, no `property` marker, and its only suite is the full + generation matrix (slow; disk-hungry enough to need a `TMPDIR` override; + needs `vcs_ref=HEAD`). CI's `test` job (3 Python × 2 OS) is the enforcer. +2. **ruff: local `uv run ruff` hooks**, not the `astral-sh/ruff-pre-commit` + repo. Single source of truth = the locked ruff `0.15.19` that `just lint` + and CI already use; `ruff-pre-commit` pins `0.15.18`, which would drift and + could reformat in a way CI's `ruff format --check` then rejects. Consistent + with how basedpyright already runs as a `uv run` local hook. +3. **CI: local-only (no CI job).** Mirrors the template exactly — downstream CI + runs `just ci`, which never invokes pre-commit. The substantive checks + (ruff, basedpyright) are already CI-enforced by the existing `lint` and + `typecheck` jobs; the hygiene hooks stay a local gate. + +## The config — `.pre-commit-config.yaml` (repo root) + +```yaml +minimum_pre_commit_version: "4.0.0" +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 + hooks: + - id: check-merge-conflict + args: [--assume-in-merge] + - id: end-of-file-fixer + exclude: '^template/' + - id: trailing-whitespace + exclude: '^template/' + + - repo: local + hooks: + - id: ruff-check + name: ruff check (--fix) + entry: uv run ruff check --fix --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: ruff-format + name: ruff format + entry: uv run ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: basedpyright + name: basedpyright (type check) + entry: uv run basedpyright + language: system + types: [python] + pass_filenames: false + stages: [pre-push] + - id: forbid-rej + name: forbid copier .rej conflict files + entry: "unresolved copier .rej conflict files present; resolve and delete them" + language: fail + files: '\.rej$' +``` + +## Wiring + +### `pyproject.toml` +Add `pre-commit>=4,<5` (mirrors `minimum_pre_commit_version: "4.0.0"`) to +`[dependency-groups] dev`, then `uv lock` + `uv sync`. + +### `justfile` +Add a bootstrap recipe (the maintainer is **not** a copier-generated project, +so the copy-time `_tasks` `pre-commit install` never runs for it — without a +bootstrap path the hooks may never get installed), and a `precommit` recipe +that runs **both** hook stages (a bare `pre-commit run --all-files` is +commit-stage only and would silently skip the pre-push basedpyright hook): + +```just +# one-time: sync the venv and install the git hooks (maintainer is not copier-generated) +setup: + uv sync + uv run pre-commit install + +# run every hook over the whole tree: commit-stage hooks, then pre-push basedpyright +precommit: + uv run pre-commit run --all-files + uv run pre-commit run --all-files --hook-stage pre-push +``` + +The second `precommit` line runs basedpyright plus `end-of-file-fixer` / +`trailing-whitespace` (the two fixers inherit a `pre-push` stage from the pinned +`pre-commit-hooks` v6.0.0 manifest; ruff / check-merge-conflict / forbid-rej are +commit-stage only). The fixers therefore run in both lines — harmless on a clean +tree (idempotent) — and basedpyright duplicates `just typecheck`, which is +acceptable. `precommit` is **not** added to any aggregate `ci` recipe — when gap +#3's `just ci` lands, keep the mutating pre-commit hooks out of it. + +### `AGENTS.md` +Add a new peer `## Pre-commit` H2 immediately after "Lint & format this repo". +It must not repeat the "CI enforces it" refrain (the hygiene hooks have no CI +backstop), must document the two divergences from what we ship, and must record +that the root and template `pre-commit-hooks` `rev:` pins move together: + +````markdown +## Pre-commit + +```bash +just setup # one-time: sync the venv and install the git hooks +just precommit # run every hook (commit-stage + pre-push basedpyright) over the whole tree +``` + +`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. + +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. + +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…`). +```` + +## Documented divergences (mirror-except-where-surface-differs) + +- **ruff local hooks** vs shipped `ruff-pre-commit` — eliminates the + 0.15.18/0.15.19 drift. Cost: the maintainer no longer exercises the + `ruff-pre-commit` mechanism it ships; that mechanism is already covered by + `test_generation.py`'s rendered `pre-commit run --all-files`, so no coverage + is lost. +- **pytest dropped** — CI's matrix is the enforcer. +- **No downstream-style auto-install** — replaced by `just setup`. +- **`trailing-whitespace` has no `--markdown-linebreaks`** — omitted for + template parity; be aware future two-space Markdown hard breaks get stripped. + +## Acceptance + +Stage the new file first (`--all-files` == `git ls-files`, so an unstaged new +config is invisible), then run both stages and confirm green: + +```bash +git add -A +uv run pre-commit run --all-files # hygiene + ruff + forbid-rej +uv run pre-commit run --all-files --hook-stage pre-push # basedpyright +``` + +Fix any hygiene findings in the **same commit** so the gate starts green +(empirically the diff is currently empty). No CI job is added; no +`test_generation.py` change is owed (the maintainer's own config is not a +`template/` file, so the "every template file needs a generation-test" rule +does not fire). No CHANGELOG `[Unreleased]` entry (CHANGELOG is downstream / +template-scoped). + +## Out of scope / tracked follow-up + +- Aggregate `just ci` (gap #3) and any CI pre-commit job. +- **Reconcile the shipped `ruff-pre-commit` v0.15.18 pin with the locked ruff + 0.15.19 at the source** — this is a real *template* defect, not scope-free. + The natural lever is a Renovate rule syncing the downstream `ruff-pre-commit` + rev to the resolved ruff (the shipped `renovate.json` already enables the + pre-commit manager), or evaluating moving the template's ruff to local hooks + too (weighing the pre-`uv sync` bootstrap regression). File a template-level + ticket. + +## Endorsed decisions (verified in review — do not reopen) + +SHA `3e8a8703…` is authentic `pre-commit-hooks` v6.0.0; `exclude: '^template/'` +on only the two mutating hygiene hooks is correct and sufficient; +check-merge-conflict is correctly not excluded; local ruff hooks resolve to +locked 0.15.19 and `.jinja` files never reach them (tagged jinja, not python); +dropping pytest is correct; basedpyright at pre-push mirrors the CI typecheck +job; `default_install_hook_types` means a single `pre-commit install` wires both +git hooks; local-only is the more faithful mirror; the tree starts green. From a220409e45a64c9da6bd5661ae13b5cc2e1143e9 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 19:46:02 -0600 Subject: [PATCH 2/6] docs(plans): add dogfood pre-commit implementation plan Task-by-task plan to implement the pre-commit design (gap #1), with per-step verification commands and expected output empirically confirmed by a worktree-isolated dry-run: the four implementation commits land green with a clean tree. Includes a forbid-rej negative check and corrected pre-push hook behavior (end-of-file-fixer/trailing-whitespace inherit a pre-push stage). --- .../plans/2026-07-01-dogfood-precommit.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-dogfood-precommit.md diff --git a/docs/superpowers/plans/2026-07-01-dogfood-precommit.md b/docs/superpowers/plans/2026-07-01-dogfood-precommit.md new file mode 100644 index 0000000..d89bd8c --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-dogfood-precommit.md @@ -0,0 +1,285 @@ +# Dogfood Pre-commit Implementation Plan + +> **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. + +**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. + +**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). + +**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. + +**Source spec:** `docs/superpowers/specs/2026-07-01-dogfood-precommit-design.md` + +## Global Constraints + +- Branch: work on `chore/dogfood-precommit` (already contains the spec commit `2cbacac`). Do not branch again. +- `pre-commit>=4,<5` — mirrors `minimum_pre_commit_version: "4.0.0"`. +- 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). +- Hygiene hooks (`end-of-file-fixer`, `trailing-whitespace`) carry `exclude: '^template/'` — `template/` is Jinja and its whitespace is deliberate (`keep_trailing_newline: true`). +- Local ruff hooks use `uv run ruff` (locked 0.15.19), NOT `astral-sh/ruff-pre-commit`. +- No pytest hook; no CI pre-commit job; no `test_generation.py` change; no CHANGELOG `[Unreleased]` entry (CHANGELOG is downstream/template-scoped). +- Commits: Conventional Commit format, no AI-attribution trailer, GPG-signed (automatic in this repo). Author is `Ashlen `. +- 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. +- First `pre-commit run` downloads the `pre-commit-hooks` environment at the pinned SHA (needs network); subsequent runs are cached. + +--- + +### Task 1: Add pre-commit dev dependency + +**Files:** +- Modify: `pyproject.toml` (the `[dependency-groups] dev` array) +- Modify: `uv.lock` (regenerated) + +**Interfaces:** +- Consumes: nothing. +- Produces: `uv run pre-commit` becomes available for all later tasks. + +- [ ] **Step 1: Add the dependency** + +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. + +- [ ] **Step 2: Lock and sync** + +Run: `uv lock && uv sync` +Expected: `uv.lock` updates to include `pre-commit` (and its deps: `cfgv`, `identify`, `nodeenv`, `virtualenv`, etc.); `uv sync` installs them into `.venv`. + +- [ ] **Step 3: Verify pre-commit is runnable at the required major** + +Run: `uv run pre-commit --version` +Expected: prints `pre-commit 4.x.y` (major 4). + +- [ ] **Step 4: Verify the lock recorded pre-commit** + +Run: `grep -A1 'name = "pre-commit"' uv.lock | head -2` +Expected: shows `name = "pre-commit"` and a `version = "4...."` line. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build(deps): add pre-commit dev dependency" +``` + +--- + +### Task 2: Add the root pre-commit config + +**Files:** +- Create: `.pre-commit-config.yaml` + +**Interfaces:** +- Consumes: `uv run pre-commit` (Task 1); `uv run ruff`, `uv run basedpyright` (already present). +- Produces: a validated pre-commit config that later recipes/tasks invoke. + +- [ ] **Step 1: Create `.pre-commit-config.yaml`** + +```yaml +minimum_pre_commit_version: "4.0.0" +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 + hooks: + - id: check-merge-conflict + args: [--assume-in-merge] + - id: end-of-file-fixer + exclude: '^template/' + - id: trailing-whitespace + exclude: '^template/' + + - repo: local + hooks: + - id: ruff-check + name: ruff check (--fix) + entry: uv run ruff check --fix --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: ruff-format + name: ruff format + entry: uv run ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: basedpyright + name: basedpyright (type check) + entry: uv run basedpyright + language: system + types: [python] + pass_filenames: false + stages: [pre-push] + - id: forbid-rej + name: forbid copier .rej conflict files + entry: "unresolved copier .rej conflict files present; resolve and delete them" + language: fail + files: '\.rej$' +``` + +- [ ] **Step 2: Validate the config parses** + +Run: `uv run pre-commit validate-config .pre-commit-config.yaml` +Expected: no output, exit code 0 (invalid YAML/schema would print an error). + +- [ ] **Step 3: Stage the new file so `--all-files` sees it, then run commit-stage hooks** + +`--all-files` operates on `git ls-files`; an unstaged new file is invisible. + +Run: `git add .pre-commit-config.yaml && uv run pre-commit run --all-files` +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. + +- [ ] **Step 4: Run the pre-push stage hook** + +Run: `uv run pre-commit run --all-files --hook-stage pre-push` +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. + +- [ ] **Step 5: Confirm no files were modified** + +Run: `git status --short` +Expected: only `A .pre-commit-config.yaml` staged; no unexpected modifications from the fixer hooks. + +- [ ] **Step 6: Prove `forbid-rej` has teeth (negative check)** + +Every other step exercises only the green path; confirm the one un-CI-backstopped, repo-authored hook can actually fail. + +Run: `printf 'x\n' > scratch.rej && uv run pre-commit run forbid-rej --files scratch.rej; rm -f scratch.rej` +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. + +- [ ] **Step 7: Commit** + +```bash +git add .pre-commit-config.yaml +git commit -m "chore(pre-commit): add local pre-commit config" +``` + +--- + +### Task 3: Add `just setup` and `just precommit` recipes + +**Files:** +- Modify: `justfile` + +**Interfaces:** +- Consumes: `.pre-commit-config.yaml` (Task 2). +- Produces: `just setup` (installs the git hooks) and `just precommit` (runs both hook stages). + +- [ ] **Step 1: Add the recipes to `justfile`** + +Add, after the existing `fmt-check` recipe: + +```just + +# one-time: sync the venv and install the git hooks (maintainer is not copier-generated) +setup: + uv sync + uv run pre-commit install + +# run every hook over the whole tree: commit-stage hooks, then pre-push basedpyright +precommit: + uv run pre-commit run --all-files + uv run pre-commit run --all-files --hook-stage pre-push +``` + +- [ ] **Step 2: Verify the recipes are listed** + +Run: `just --list` +Expected: `setup` and `precommit` appear among the recipes with their doc comments. + +- [ ] **Step 3: Install the git hooks** + +Run: `just setup` +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`. + +- [ ] **Step 4: Verify both git hooks were written** + +Run: `test -f "$(git rev-parse --git-path hooks/pre-commit)" && test -f "$(git rev-parse --git-path hooks/pre-push)" && echo OK` +Expected: prints `OK` (both hook files exist). + +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. + +- [ ] **Step 5: Run the full local gate via the recipe** + +Run: `just precommit` +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. + +- [ ] **Step 6: Commit** + +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). + +```bash +git add justfile +git commit -m "chore: add just setup and precommit recipes" +``` + +--- + +### Task 4: Document pre-commit in AGENTS.md + +**Files:** +- Modify: `AGENTS.md` (insert a new `## Pre-commit` section immediately after the "Lint & format this repo" section, before "Add a guardrail layer") + +**Interfaces:** +- Consumes: the recipes (Task 3) and config (Task 2) that the section describes. +- Produces: nothing downstream. + +- [ ] **Step 1: Insert the `## Pre-commit` section** + +Insert immediately after the "Lint & format this repo" section's final paragraph and before `## Add a guardrail layer`: + +````markdown +## Pre-commit + +```bash +just setup # one-time: sync the venv and install the git hooks +just precommit # run every hook (commit-stage + pre-push basedpyright) over the whole tree +``` + +`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. + +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. + +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…`). +```` + +- [ ] **Step 2: Verify the section is present and placed correctly** + +Run: `grep -nE '^## ' AGENTS.md` +Expected: `## Pre-commit` appears between `## Lint & format this repo` and `## Add a guardrail layer`. + +- [ ] **Step 3: Verify AGENTS.md passes the hygiene hooks (it is not under template/, so it is checked)** + +Run: `git add AGENTS.md && uv run pre-commit run --files AGENTS.md` +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). + +- [ ] **Step 4: Commit** + +```bash +git add AGENTS.md +git commit -m "docs(agents): document pre-commit workflow" +``` + +--- + +### Task 5: Final acceptance + +**Files:** none (verification only). + +- [ ] **Step 1: Full gate green from a clean tree** + +Run: `just precommit` +Expected: commit-stage line — five hooks `Passed` and `forbid-rej` `Skipped`; pre-push line — `end-of-file-fixer`, `trailing-whitespace`, `basedpyright` `Passed`; exit 0. + +- [ ] **Step 2: Confirm working tree is clean (no hook left an unstaged fix)** + +Run: `git status --short` +Expected: empty output. + +- [ ] **Step 3: Confirm all commits are present on the branch** + +Run: `git log --oneline main..HEAD` +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)…`). + +- [ ] **Step 4: Push and open the PR** (only when instructed by the operator) + +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). From 3408fa3802c1a1228e2e7fb6af73c3d25ec4b30c Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 19:49:40 -0600 Subject: [PATCH 3/6] build(deps): add pre-commit dev dependency --- pyproject.toml | 1 + uv.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9b377ff..b1854b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "pyyaml>=6,<7", "basedpyright>=1.39,<1.40", "ruff>=0.15,<0.16", + "pre-commit>=4,<5", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index f285ab4..0992ab7 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/94/878454aefe94328ba7ad808ecd63da8311aae1198da46cfb29f5cfe130a8/basedpyright-1.39.8-py3-none-any.whl", hash = "sha256:a79d89928064bd9023d429b50c625d87d023bacc2fe3932ef6c7bd13b5426048", size = 13182726, upload-time = "2026-06-14T09:05:26.368Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -56,6 +65,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/7d/733c088265e01e653c70e89e58345a3dd346acc34af26987314b84b9ffaa/copier-9.15.2-py3-none-any.whl", hash = "sha256:50a39142d6493f2261762309374a7a7462dbab654c5d22fd9aa17b6a69b84a95", size = 63389, upload-time = "2026-06-12T08:54:10.991Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "dunamai" version = "1.26.1" @@ -68,6 +86,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/bc/8b8ec5a4bfc5b9cf3ce27a118339e994f88410be5677c96493e0ea28e76d/dunamai-1.26.1-py3-none-any.whl", hash = "sha256:2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853", size = 27332, upload-time = "2026-04-04T14:07:10.079Z" }, ] +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + [[package]] name = "funcy" version = "2.0" @@ -77,6 +104,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/08/c2409cb01d5368dcfedcbaffa7d044cc8957d57a9d0855244a5eb4709d30/funcy-2.0-py2.py3-none-any.whl", hash = "sha256:53df23c8bb1651b12f095df764bfb057935d49537a56de211b098f4c79614bb0", size = 30891, upload-time = "2023-03-28T06:22:42.576Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -185,6 +221,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "nodejs-wheel-binaries" version = "24.16.0" @@ -249,6 +294,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/2d/d741fbbbcba7eb6f9f1a829373c558114d59cb882b95f941aa0dc060861f/plumbum-2.0.1-py3-none-any.whl", hash = "sha256:27a454980f91689aae8f18242a36daaf2636219171cf0e6a849744aa1d6fff85", size = 164460, upload-time = "2026-06-08T14:44:04.75Z" }, ] +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -403,6 +464,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + [[package]] name = "python-kickstarter" version = "0.1.0" @@ -413,6 +487,7 @@ dev = [ { name = "basedpyright" }, { name = "copier" }, { name = "plumbum" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pyyaml" }, { name = "ruff" }, @@ -425,6 +500,7 @@ dev = [ { name = "basedpyright", specifier = ">=1.39,<1.40" }, { name = "copier", specifier = ">=9.6,<10" }, { name = "plumbum", specifier = ">=1.8,<3" }, + { name = "pre-commit", specifier = ">=4,<5" }, { name = "pytest", specifier = ">=8,<10" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "ruff", specifier = ">=0.15,<0.16" }, @@ -543,6 +619,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + [[package]] name = "wcwidth" version = "0.8.1" From a368849c952e14922c4560376ee015d126f29661 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 19:50:45 -0600 Subject: [PATCH 4/6] chore(pre-commit): add local pre-commit config --- .pre-commit-config.yaml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..35f98d0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +minimum_pre_commit_version: "4.0.0" +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 + hooks: + - id: check-merge-conflict + args: [--assume-in-merge] + - id: end-of-file-fixer + exclude: '^template/' + - id: trailing-whitespace + exclude: '^template/' + + - repo: local + hooks: + - id: ruff-check + name: ruff check (--fix) + entry: uv run ruff check --fix --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: ruff-format + name: ruff format + entry: uv run ruff format --force-exclude + language: system + types_or: [python, pyi] + require_serial: true + - id: basedpyright + name: basedpyright (type check) + entry: uv run basedpyright + language: system + types: [python] + pass_filenames: false + stages: [pre-push] + - id: forbid-rej + name: forbid copier .rej conflict files + entry: "unresolved copier .rej conflict files present; resolve and delete them" + language: fail + files: '\.rej$' From 4a5f8c638b28ee513c5a47427d7899a2b99c4ae3 Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 19:51:47 -0600 Subject: [PATCH 5/6] chore: add just setup and precommit recipes --- justfile | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/justfile b/justfile index 7f26747..084fbfa 100644 --- a/justfile +++ b/justfile @@ -25,3 +25,13 @@ fmt: # CI's format gate: fail if anything is unformatted. fmt-check: uv run ruff format --check . + +# one-time: sync the venv and install the git hooks (maintainer is not copier-generated) +setup: + uv sync + uv run pre-commit install + +# run every hook over the whole tree: commit-stage hooks, then pre-push basedpyright +precommit: + uv run pre-commit run --all-files + uv run pre-commit run --all-files --hook-stage pre-push From e344f5d58f346321917148c829a327e6ad81723f Mon Sep 17 00:00:00 2001 From: Ashlen Date: Wed, 1 Jul 2026 19:52:53 -0600 Subject: [PATCH 6/6] docs(agents): document pre-commit workflow --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5cc90b8..c09051f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,19 @@ just fmt-check # ruff format --check . (CI's format gate) The maintainer harness runs the same full `select=["ALL"]` ruleset the template ships, scoped to `tests/` (the only Python surface; `template/` is Jinja). Every config-level ignore is load-bearing and audited — there are **no inline `# noqa`**. CI enforces `ruff check` + `ruff format --check` (the `lint` job in `.github/workflows/test-template.yml`). +## Pre-commit + +```bash +just setup # one-time: sync the venv and install the git hooks +just precommit # run every hook (commit-stage + pre-push basedpyright) over the whole tree +``` + +`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. + +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. + +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…`). + ## Add a guardrail layer 1. Add an `enable_*` toggle to `copier.yml`.