From 0e37d71e152a5296c711644ca3889e7822942cdd Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 14:22:38 -0700 Subject: [PATCH 1/8] Make Local Pre-Commit Hooks Strongly Suggested Fleet-Wide Agents repeatedly skip local linting, commit anyway, and let review catch prose and comment-style mistakes a working hook would have caught instantly. This elevates local commit hooks from optional/opt-in framing to a strongly suggested fleet norm, backed by a new audit check. Both hook shapes gain the same diff-scoped prose gate and eol check, fetched fresh from this repo's own main branch rather than vendored or pinned, so nothing goes stale. The Husky.Net snippet gains a real ruff block. A new canonical Python pre-commit framework config exists for the first time. spec/project-types.json gains parity.hooks: a repo with no hook wired at all is now a linter-parity defect, not an invisible gap. Downstream repo conversion is tracked in docs/pre-commit-hooks-rollout.md and lands as separate resync PRs, not part of this change. --- .agents/skills/dotnet-codestyle/SKILL.md | 10 ++- .agents/skills/python-codestyle/SKILL.md | 7 +- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/dotnet-codestyle/SKILL.md | 10 ++- .../skills/python-codestyle/SKILL.md | 7 +- .github/skills/dotnet-codestyle/SKILL.md | 10 ++- .github/skills/python-codestyle/SKILL.md | 7 +- AUDIT.md | 2 +- CODESTYLE.md | 2 +- GOVERNANCE.md | 6 +- STANDUP.md | 3 + TODO.md | 1 - catalog/README.md | 1 + .../snippets/configs/vscode-tasks-python.json | 32 +++++++- catalog/snippets/configs/vscode-tasks.json | 29 ++++++- catalog/snippets/hub-fetch-run.py | 55 +++++++++++++ catalog/snippets/husky/README.md | 25 +++++- catalog/snippets/husky/pre-commit | 32 ++++++-- .../pre-commit/.pre-commit-config.yaml | 36 ++++++++ catalog/snippets/pre-commit/README.md | 25 ++++++ docs/pre-commit-hooks-rollout.md | 82 +++++++++++++++++++ spec/project-types.json | 3 +- spec/third-party-tools.json | 1 + 23 files changed, 350 insertions(+), 38 deletions(-) create mode 100644 catalog/snippets/hub-fetch-run.py create mode 100644 catalog/snippets/pre-commit/.pre-commit-config.yaml create mode 100644 catalog/snippets/pre-commit/README.md create mode 100644 docs/pre-commit-hooks-rollout.md diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index ee8d9a96..de797687 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,10 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` - as a style step) for pre-commit enforcement, but CI is the gate that matters. + A working local hook is strongly suggested, not optional: wire Husky.Net (`dotnet husky run` as + a style step, plus the shared prose/EOL doc gates) from the canonical + `catalog/snippets/husky/` config, since a repo with no hook wired at all is now a measured audit + finding, per GOVERNANCE.md "Running the Linters Locally". **A new port is not a license to silence diagnostics.** Brownfield or just-ported status never justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only @@ -94,8 +96,8 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or - another runner) if you want local enforcement. +- CI is the authoritative lint backstop. A local pre-commit hook is strongly suggested: wire + Husky.Net from `catalog/snippets/husky/` for local enforcement, including the shared doc gates. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index 02697a72..cc09ba09 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -90,8 +90,11 @@ The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the rep both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so -wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. +CI runs the same clean-compile commands as the authoritative backstop. A working local hook is +strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical +`catalog/snippets/pre-commit/.pre-commit-config.yaml` (ruff, the type checker, and the shared +prose/EOL doc gates), since a repo with no hook wired at all is now a measured audit finding, per +GOVERNANCE.md "Running the Linters Locally". A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index cc914f52..6b18f619 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -85b7db1858ecd00a +1b671ebb15a74698 diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index ee8d9a96..de797687 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,10 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` - as a style step) for pre-commit enforcement, but CI is the gate that matters. + A working local hook is strongly suggested, not optional: wire Husky.Net (`dotnet husky run` as + a style step, plus the shared prose/EOL doc gates) from the canonical + `catalog/snippets/husky/` config, since a repo with no hook wired at all is now a measured audit + finding, per GOVERNANCE.md "Running the Linters Locally". **A new port is not a license to silence diagnostics.** Brownfield or just-ported status never justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only @@ -94,8 +96,8 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or - another runner) if you want local enforcement. +- CI is the authoritative lint backstop. A local pre-commit hook is strongly suggested: wire + Husky.Net from `catalog/snippets/husky/` for local enforcement, including the shared doc gates. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index 02697a72..cc09ba09 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -90,8 +90,11 @@ The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the rep both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so -wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. +CI runs the same clean-compile commands as the authoritative backstop. A working local hook is +strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical +`catalog/snippets/pre-commit/.pre-commit-config.yaml` (ruff, the type checker, and the shared +prose/EOL doc gates), since a repo with no hook wired at all is now a measured audit finding, per +GOVERNANCE.md "Running the Linters Locally". A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index ee8d9a96..de797687 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,10 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` - as a style step) for pre-commit enforcement, but CI is the gate that matters. + A working local hook is strongly suggested, not optional: wire Husky.Net (`dotnet husky run` as + a style step, plus the shared prose/EOL doc gates) from the canonical + `catalog/snippets/husky/` config, since a repo with no hook wired at all is now a measured audit + finding, per GOVERNANCE.md "Running the Linters Locally". **A new port is not a license to silence diagnostics.** Brownfield or just-ported status never justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only @@ -94,8 +96,8 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. Local pre-commit hooks are optional, wire Husky.Net (or - another runner) if you want local enforcement. +- CI is the authoritative lint backstop. A local pre-commit hook is strongly suggested: wire + Husky.Net from `catalog/snippets/husky/` for local enforcement, including the shared doc gates. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index 02697a72..cc09ba09 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -90,8 +90,11 @@ The Python clean-compile is `uv run ruff format` + `uv run ruff check` + the rep both (see Type checking above). Run it, plus `uv run pytest`, before committing. These are documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. -CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so -wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. +CI runs the same clean-compile commands as the authoritative backstop. A working local hook is +strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical +`catalog/snippets/pre-commit/.pre-commit-config.yaml` (ruff, the type checker, and the shared +prose/EOL doc gates), since a repo with no hook wired at all is now a measured audit finding, per +GOVERNANCE.md "Running the Linters Locally". A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before diff --git a/AUDIT.md b/AUDIT.md index 926db34b..03d57b71 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -87,7 +87,7 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the - **carried-scope** - the repo carries no file the hub hosts rather than carries. The set is derived, not listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline, so a file dropped from the manifest starts being reported on the next run with no retirement list to remember to edit. The remedy is the opposite of every other file finding, a **deletion**, since the repo reaches the hub's copy per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The match is on path alone, so a hit is a candidate and not a verdict: a repo's own content at a path the hub also uses matches while carrying nothing of the hub's, which the first fleet run showed twice, a KiCad tooling doc at `scripts/README.md` and per-repo formatting hooks at `.husky/pre-commit`. A [`spec/divergences.json`][divergences] `gaps` disposition decides which case a hit is, so only `retire` asserts a deletion, `accepted` closes a collision or a repo-owned file, and an untriaged hit is read before it is acted on. - **verbatim-tree** - every applicable `trees[]` declaration in [`spec/files.json`][files] owns its target tree. The audit reports missing files as letter findings, stale or modified bytes as drift, and extra files under a pruned target as drift. An unreadable or truncated repository tree is undecided and produces drift rather than a clean result. - **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). -- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. A local hook exists and runs at minimum the diff-scoped prose gate and the eol check (`parity.hooks`, intent). A repo with none wired is a defect, and one mid-convergence on the language-formatting half stays operational. - **recurring-violations** - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, so this dimension is high priority and always runs, and each check is grep-able (see below). - **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). Mechanically checked against the declared model in [`spec/readme-sections.json`][readme-sections]: required sections present, declared sections in their relative order, `License` last, the shields each deliverable implies, the license shield in the closing License section, and the tagline and its mirrors. A heading the model does not name is dropped before the order comparison, so a repo-specific section is never a finding. diff --git a/CODESTYLE.md b/CODESTYLE.md index 2569b380..7a7badc7 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -18,7 +18,7 @@ Each language defines a **clean-compile** verification: the combination of build - **Run it after every code change, and it is not the whole gate.** The relevant language's clean-compile must pass before you commit. CI runs those same language checks as a backstop **plus everything else its validation workflow runs**, and all of it reports into the one required status, so a green clean-compile does not predict a green CI. That remainder is at least the doc-lint set (markdownlint, cspell, actionlint, `editorconfig-checker`) and whatever spec, config, and script gates the repo carries, so read the workflow for the full list rather than assuming this sentence enumerates it. What has to pass before a push is the repo's **whole** lint gate, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. Each linter's known-working invocation is in [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally]. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly, and either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). -- **A local commit/pre-commit gate is the repo's choice.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so none is mandated, but that is **not** a recommendation against commit gates. CI is the authoritative backstop regardless, and a local gate is an additive convenience a repo may wire and keep: Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python. Keeping a working gate is not drift. +- **A working local commit/pre-commit gate is strongly suggested, and the audit now measures it.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so the *mechanism* choice is the repo's: Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python, canonical configs for both in `catalog/snippets/`. CI is still the authoritative backstop, but a repo with no hook wired at all is a `linter-parity` defect, not an invisible gap, per [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally]. A repo mid-convergence (the doc-gate half wired, the language-format half not yet, because its own corpus does not pass it clean) stays operational rather than out of conformance. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 4b6dad89..5772c1df 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -219,8 +219,10 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): - **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. -- **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. -- **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. +- **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. +- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. +- **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. This is the one network call the hook makes, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or `uv run pre-commit install`, and CI remains the authoritative run either way. +- **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. The Docker invocations below run the same tools and configs as the VS Code tasks. Their headless form separates the image pull and minimizes repository exposure for an agent executor. diff --git a/STANDUP.md b/STANDUP.md index 4dbd33c4..f90e46bb 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -179,6 +179,8 @@ Carry `AGENTS.md`'s skill-dependency pointer paragraph, the one naming `scripts/ Choose the destination while scaffolding rather than after. Repo-specific content left in a carried file is drift, which the audit lists as an undeclared section to reconcile, and reconciling it later means moving prose that downstream readers have already started trusting in the wrong place. +**Wire a local commit hook here, not after the fact.** `.husky/pre-commit` and `.pre-commit-config.yaml` are deliberately excluded from the baseline above (each repo's own formatters make the content repo-owned, per [`spec/divergences.json`][divergences]), so nothing in the carry step above wires one. Copy and adapt the applicable catalog snippet, `catalog/snippets/husky/` for Husky.Net or `catalog/snippets/pre-commit/` for the Python `pre-commit` framework, each with `catalog/snippets/hub-fetch-run.py` alongside it, and enable it (`git config core.hooksPath .husky` or `uv run pre-commit install`) before the section 5 audit run. A freshly stood-up repo with nothing wired starts pre-failed on `parity.hooks`. + ## 3. Stand Up the Workflows Implement the Actions that satisfy [`WORKFLOW.md`][workflow] for the repo's type (its section 6 per-type walkthrough): the source-only subset for a source-only repo, the file-target leaf(s) for a publishing repo, the two-workflow shape for an operational config repo. Reuse [`catalog/snippets/workflows/`][workflows] as the reference implementation, satisfying the contract by outcome rather than byte for byte. @@ -236,6 +238,7 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [audit]: ./AUDIT.md [codestyle]: ./CODESTYLE.md [content-import]: ./docs/content-import.md +[divergences]: ./spec/divergences.json [files]: ./spec/files.json [fleet-map]: ./docs/fleet-map.md [git-commit-conventions]: ./.agents/skills/git-commit-conventions/SKILL.md diff --git a/TODO.md b/TODO.md index c8ddb5a3..95de391b 100644 --- a/TODO.md +++ b/TODO.md @@ -406,7 +406,6 @@ Blog carries `* -text` plus explicit named `eol=lf` pins plus a dedicated rotted Small work with no research to preserve, selectable one bullet at a time. - **Answer the symmetric reading of [`.editorconfig`][editorconfig], a path-specific section naming files that do not exist**, which is the half of [#633][issue-633] the `eol-coverage` check deliberately left open. The dead-pin reading it does ship is the `.gitattributes` side, and the same question on the other document is not the same shape: this repo's `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, and the issue's own first attempt at it produced false positives because the matcher did not expand brace syntax, which [`scripts/repo_gate.py`][repo-gate] already implements. Measure the exemption against the live corpus before building the gate rather than after, since a stale exemption hands out a work list that damages correct documents, and decide whether `forward-declared` carries across or whether an editorconfig section needs its own marker. -- **Reconsider whether the pre-commit hook runs the doc gates now that they are diff-scoped.** [`scripts/README.md`][scripts] records the current decision and its reason, that doc linters stay out of the hook so it stays fast, which was sound when the only mode was a whole-tree sweep, and a diff-scoped run finishes in about a second. The failure it would prevent is the most repeated one on record, comment sentences wrapped across lines caught after the commit rather than before it. Weigh it against the standing preference for a fast hook. The other objection, a hook running the gate from the wrong directory and reporting its own false clean, no longer applies: the rule set, the file set, the diff, and the keys joining them are all read from the repository being scanned rather than from wherever the process stands. - **Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is cheaper.** The evidence is the review record rather than a language preference, since a non-trivial shell script earns findings round after round while every gate under [`scripts/`][scripts] carries a test file under `scripts/tests/` and converges in one or two. The measure is lines, branch count, and the review rounds each has cost. `repo-config/configure.sh` and the agent-safety installer are the two worth measuring, and a bootstrap script that needs the Python it exists to install is not a rewrite worth having, which protects the installer more than the config script. - **Make a table of contents standard for a long document rather than for the README alone.** [`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and no other hub file carries one, which leaves the three longest documents without it, `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 436 and `WORKFLOW.md` at 301, measured on `develop` at `3d1a0b1` on 2026-08-06. Settle the threshold in headings or lines so the audit can check it, and settle how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the property that makes a contents list worth having in them. The mechanical constraint is that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale list, which is worse than absent because it is read as current. - **Converge this repo's Python on the ruff configuration it already declares, then add the formatting half to the pre-commit hook.** `pyproject.toml` carries `[tool.ruff]` and [`spec/project-types.json`][project-types] declares `python.ruff.config`, yet no workflow runs ruff and the tree does not pass it, measured on `develop` at `6d020b1` on 2026-08-09 with ruff 0.16.2: `ruff format --check` reports 13 of 57 files would be reformatted, and `ruff check` reports 106 errors, of which 39 are auto-fixable. The largest groups are 24 `PLW1510` (a `subprocess.run` with no `check`), 17 `FURB167` (`re.M` for `re.MULTILINE`), 11 `EXE001` (a shebang on a non-executable file, which wants reading against the `eol-coverage` shebang set rather than fixed blindly), 9 `BLE001` and 9 `SIM117`. The hook deliberately ships without the ruff step for this reason, since a gate failing on the corpus it guards blocks every commit from the moment it lands, which is the measure-the-corpus-first rule applied to a gate rather than to an exemption. Decide whether CI gains a ruff job in the same pass, since a formatter enforced only by a hook is enforced only on the machines that enabled it. diff --git a/catalog/README.md b/catalog/README.md index 940689d9..8269cc2e 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -7,3 +7,4 @@ Reusable reference snippets: concrete config artifacts a repo can copy or compar - `snippets/configs/`: the config exemplars `vscode-tasks.json` (.NET clean-compile task group) and `vscode-tasks-python.json` (the Python equivalent, running `ruff`/type-check/`pytest`, all `type: process` so no `&&` chaining breaks Windows PowerShell 5.1), plus `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). - `snippets/devcontainer/`: `.devcontainer` definitions for the .NET and Python toolchains. - `snippets/vscode/`: the composable `.code-workspace` fragments: `base.jsonc` (standard set) plus `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` per-type additions. See `snippets/vscode/README.md`. +- `snippets/husky/` and `snippets/pre-commit/`: the two local commit-hook shapes, Husky.Net for .NET (or any project, ruff included) and the Python `pre-commit` framework, both carrying the same shared doc gates (prose/comment-style, line endings) via `snippets/hub-fetch-run.py`. See each directory's own README.md. diff --git a/catalog/snippets/configs/vscode-tasks-python.json b/catalog/snippets/configs/vscode-tasks-python.json index 5f2068c7..e35e6c13 100644 --- a/catalog/snippets/configs/vscode-tasks-python.json +++ b/catalog/snippets/configs/vscode-tasks-python.json @@ -123,8 +123,10 @@ // Lint group, run on demand via Docker, where --pull=always forces each to re-pull :latest. // It mirrors the CI lint gate. // Broad live spell-checking is the cspell extension's job. - // Pre-commit does formatting only. - // These tasks are language-agnostic, identical to the .NET snippet's Lint group. + // Pre-commit already runs the prose/EOL gates diff-scoped. + // Lint: Prose and Lint: EOL below add the same two checks in whole-repo mode, for on-demand full-tree validation. + // Lint: EditorConfig/Workflows/Markdown/Spelling are language-agnostic, identical to the + // .NET snippet's Lint group. { "label": "Lint: EditorConfig", "type": "process", @@ -169,6 +171,28 @@ "clear": false } }, + { + "label": "Lint: Prose", + "type": "process", + "command": "python3", + "args": [ "${workspaceFolder}/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: EOL", + "type": "process", + "command": "python3", + "args": [ "${workspaceFolder}/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, { "label": "Lint: All", "dependsOrder": "sequence", @@ -176,7 +200,9 @@ "Lint: EditorConfig", "Lint: Workflows", "Lint: Markdown", - "Lint: Spelling" + "Lint: Spelling", + "Lint: Prose", + "Lint: EOL" ], "problemMatcher": [] } diff --git a/catalog/snippets/configs/vscode-tasks.json b/catalog/snippets/configs/vscode-tasks.json index f8d366fb..004b453a 100644 --- a/catalog/snippets/configs/vscode-tasks.json +++ b/catalog/snippets/configs/vscode-tasks.json @@ -124,7 +124,8 @@ // Lint group, run on demand via Docker, where --pull=always pins each to the current :latest. // It mirrors the CI lint gate. // Broad live spell-checking is the cspell extension's job. - // Pre-commit does formatting only. + // Pre-commit already runs the prose/EOL gates diff-scoped. + // Lint: Prose and Lint: EOL below add the same two checks in whole-repo mode, for on-demand full-tree validation. { "label": "Lint: EditorConfig", "type": "process", @@ -169,6 +170,28 @@ "clear": false } }, + { + "label": "Lint: Prose", + "type": "process", + "command": "python3", + "args": [ "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, + { + "label": "Lint: EOL", + "type": "process", + "command": "python3", + "args": [ "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], + "problemMatcher": [], + "presentation": { + "showReuseMessage": false, + "clear": false + } + }, { "label": "Lint: All", "dependsOrder": "sequence", @@ -176,7 +199,9 @@ "Lint: EditorConfig", "Lint: Workflows", "Lint: Markdown", - "Lint: Spelling" + "Lint: Spelling", + "Lint: Prose", + "Lint: EOL" ], "problemMatcher": [] } diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub-fetch-run.py new file mode 100644 index 00000000..e62d0da8 --- /dev/null +++ b/catalog/snippets/hub-fetch-run.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Fetch a ptr727/ProjectTemplate script fresh from `main` and run it in-process. + +Never pinned or vendored: a pin nothing keeps current goes stale by construction, and CI +(this repo's own, and the hub's) is the backstop for a change that lands broken on `main`. +A fetch failure fails the caller loudly rather than silently skipping the gate it guards. +Usage: hub-fetch-run.py [script-args...] +Example: hub-fetch-run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD +""" + +from __future__ import annotations + +import runpy +import sys +import tempfile +import urllib.error +import urllib.request +from pathlib import Path + +HUB_RAW_BASE = "https://raw.githubusercontent.com/ptr727/ProjectTemplate/main" + + +def main(argv: list[str]) -> int: + if not argv: + print("hub-fetch-run: usage: hub-fetch-run.py [script-args...]", file=sys.stderr) + return 2 + hub_path, script_args = argv[0], argv[1:] + url = f"{HUB_RAW_BASE}/{hub_path}" + try: + with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 - fixed https host + content = response.read() + except (urllib.error.URLError, OSError) as exc: + print(f"hub-fetch-run: could not fetch {url}: {exc}", file=sys.stderr) + print("hub-fetch-run: the gate did not run.", file=sys.stderr) + return 1 + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as handle: + handle.write(content) + tmp_path = Path(handle.name) + old_argv = sys.argv + try: + sys.argv = [str(tmp_path), *script_args] + try: + runpy.run_path(str(tmp_path), run_name="__main__") + except SystemExit as exc: + if exc.code is None: + return 0 + return exc.code if isinstance(exc.code, int) else 1 + return 0 + finally: + sys.argv = old_argv + tmp_path.unlink(missing_ok=True) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index 0494e9d6..1907f787 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -1,7 +1,26 @@ # Husky snippet -`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting and style only**: CSharpier and `dotnet format` style via `dotnet husky run` for .NET, or ruff for a Python repo. Native tooling and no Docker is what keeps it fast. +`pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It +runs **language formatting/lint and the fleet's shared doc gates**: CSharpier and +`dotnet format` style via `dotnet husky run` for .NET, or `ruff format --check` / `ruff check` +for a Python repo (native tooling, no Docker), plus the diff-scoped prose/comment-style gate +and the whole-tree line-ending check. Copy `../hub-fetch-run.py` alongside `pre-commit` for +the doc gates to run: it fetches those two checks fresh from `ptr727/ProjectTemplate`'s `main` +branch and runs them, rather than vendoring or pinning a copy. A pin nothing keeps current +goes stale by construction, and CI (this repo's own, and the hub's) is the backstop for a +change that lands broken on `main` before it does real damage locally. This is one more +network fetch alongside the Docker pulls the Lint tasks below already do. A fetch failure +fails the commit rather than silently skipping the gate. -Full linting (line endings, workflow YAML, Markdown, spelling) is **not** run in the hook. It runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`). Keeping the doc linters out of the hook is what keeps it simple. +Full linting (workflow YAML, Markdown, spelling, EditorConfig) is **not** run in the hook. It +runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in +`catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`), which also carries the +same prose/EOL gates in whole-repo mode for on-demand full-tree validation, not just the +diff-scoped commit-time run. Keeping the Docker-dependent doc linters out of the hook is what +keeps it fast and offline-safe. -A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in `.gitattributes` (`.husky/pre-commit text eol=lf`), git-level enforcement independent of the editor. The fleet's `[*]` `.editorconfig` default already gives it LF, no path-specific override needed. A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a non-.NET repo. +A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in +`.gitattributes` (`.husky/pre-commit text eol=lf`), git-level enforcement independent of the +editor. The fleet's `[*]` `.editorconfig` default already gives it LF, no path-specific +override needed. A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a +non-.NET repo, and drop the ruff block in a non-Python repo. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index 0820b966..cb484a09 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -1,12 +1,34 @@ #!/bin/sh -# shellcheck disable=SC1091 # Generated by Husky install, not present at lint time. . "$(dirname "$0")/_/husky.sh" -# Local pre-commit: language formatting and style only (no Docker). -# Full lint runs in CI and the VS Code Lint tasks. +# Local pre-commit: language formatting/lint, plus the fleet's shared doc gates. +# The doc gates run via hub-fetch-run.py, copied alongside this file. +# It fetches them fresh from ptr727/ProjectTemplate's `main` branch rather than vendoring or pinning them. +# See hub-fetch-run.py's own docstring, and catalog/snippets/husky/README.md, for why. +# Full doc-lint set (Markdown, spelling, workflow YAML, EditorConfig) stays in CI and the VS Code Lint tasks, since those need Docker. # .NET: CSharpier + dotnet format style via Husky.Net. -# A Python repo runs ruff here instead. if command -v dotnet >/dev/null 2>&1; then - dotnet husky run + dotnet husky run || exit 1 fi + +# Python: ruff format + ruff check via uv, native tooling. +# Drop this block in a non-Python repo. +if command -v uv >/dev/null 2>&1; then + uv run ruff format --check || exit 1 + uv run ruff check || exit 1 +fi + +# The interpreter is chosen by probing, not assumed, mirroring spec/host-tools.json's own probe order. +# On native Windows the python.org install registers `py`, not `python3`, and a `python3` found on PATH there is often the Microsoft Store alias stub, present but broken. +if python3 --version >/dev/null 2>&1; then + run_py() { python3 "$@"; } +elif py -3 --version >/dev/null 2>&1; then + run_py() { py -3 "$@"; } +else + echo "pre-commit: neither 'python3 --version' nor 'py -3 --version' ran, so the doc gates did not run." >&2 + exit 1 +fi + +run_py "$(dirname "$0")/hub-fetch-run.py" .github/actions/prose-gate/prose_lint.py . --diff HEAD || exit 1 +run_py "$(dirname "$0")/hub-fetch-run.py" .github/actions/repo-gate/repo_gate.py --check eol || exit 1 diff --git a/catalog/snippets/pre-commit/.pre-commit-config.yaml b/catalog/snippets/pre-commit/.pre-commit-config.yaml new file mode 100644 index 00000000..d014ccb2 --- /dev/null +++ b/catalog/snippets/pre-commit/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +# Reference pre-commit (pre-commit.com) config for a fleet Python repo with no .NET/Husky.Net tree. +# Copy into the repo root alongside ../hub-fetch-run.py. +# Adapt the type-check hook to match this repo's own CI checker (pyright or mypy, per python-codestyle). +# Enable with `uv run pre-commit install`. +# The type checker and ruff hooks are native/uv-run, no Docker. +# The prose/EOL hooks fetch their scripts fresh from ptr727/ProjectTemplate's `main` branch via hub-fetch-run.py, rather than vendoring or pinning a copy. +# See that file's docstring for why. +# A fetch failure fails the commit, and it never silently skips the gate. +repos: + - repo: local + hooks: + - id: ruff-format + name: ruff format --check + entry: uv run ruff format --check + language: system + pass_filenames: false + - id: ruff-check + name: ruff check + entry: uv run ruff check + language: system + pass_filenames: false + - id: type-check + name: pyright (swap for "uv run mypy" if this repo's CI runs mypy instead) + entry: uv run pyright + language: system + pass_filenames: false + - id: prose-gate + name: prose/comment-style gate (diff-scoped, fetched fresh from ProjectTemplate@main) + entry: uv run python3 hub-fetch-run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD + language: system + pass_filenames: false + - id: eol-gate + name: line-ending consistency gate (fetched fresh from ProjectTemplate@main) + entry: uv run python3 hub-fetch-run.py .github/actions/repo-gate/repo_gate.py --check eol + language: system + pass_filenames: false diff --git a/catalog/snippets/pre-commit/README.md b/catalog/snippets/pre-commit/README.md new file mode 100644 index 00000000..66985759 --- /dev/null +++ b/catalog/snippets/pre-commit/README.md @@ -0,0 +1,25 @@ +# Pre-commit snippet + +`.pre-commit-config.yaml` is the reference config for the Python `pre-commit` framework +(pre-commit.com), for a Python repo with no `.husky/` tree of its own. It runs `ruff format +--check`, `ruff check`, and this repo's declared type checker (`pyright` or `mypy`, match +whichever `python-codestyle` says this repo's CI runs) via `uv run`, native tooling, no +Docker, plus the same two shared doc gates the Husky.Net snippet carries: the diff-scoped +prose/comment-style gate and the whole-tree line-ending check. + +Copy `../hub-fetch-run.py` alongside `.pre-commit-config.yaml` (repo root) for the doc gates +to run: it fetches those two checks fresh from `ptr727/ProjectTemplate`'s `main` branch and +runs them, rather than vendoring or pinning a copy. A pin nothing keeps current goes stale by +construction, and CI (this repo's own, and the hub's) is the backstop for a change that lands +broken on `main` before it does real damage locally. This is one more network fetch alongside +the Docker pulls the VS Code Lint tasks already do. A fetch failure fails the commit rather +than silently skipping the gate. + +Enable the hook with `uv run pre-commit install`. Full linting (workflow YAML, Markdown, +spelling, EditorConfig) stays out of the hook: it runs in CI as pinned action wrappers, and on +demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks-python.json` +(Docker at `:latest`), which also carries the same prose/EOL gates in whole-repo mode for +on-demand full-tree validation, not just the diff-scoped commit-time run. + +No LF pin is needed for `.pre-commit-config.yaml` itself: it is plain YAML, not a shebang +script, so the fleet's `[*]` `.editorconfig`/`.gitattributes` default already covers it. diff --git a/docs/pre-commit-hooks-rollout.md b/docs/pre-commit-hooks-rollout.md new file mode 100644 index 00000000..00e0e742 --- /dev/null +++ b/docs/pre-commit-hooks-rollout.md @@ -0,0 +1,82 @@ +# Fleet Pre-Commit Hooks Rollout + +Tracks the fleet-wide local-hook posture change repo by repo. The policy itself lives in +[GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally] and the +canonical configs live in [`catalog/snippets/husky/`][snippets-husky] and +[`catalog/snippets/pre-commit/`][snippets-pre-commit]. This doc is the rollout checklist only, +not a restatement of the rule. It is **hub-only** and is not carried downstream, the same way +[`docs/eol-lf-rollout.md`][eol-lf-rollout] is hub-only, because it tracks the hub's own migration +rather than a fact a downstream repo's own docs need to carry. + +**Maintenance rule.** Check a repo's box in the same pull request that converts it, via the +normal `resync-a-repo` procedure, once that repo's own audit reports `parity.hooks` operational. +A register showing a repo unchecked after its conversion PR merged is itself stale prose, so this +doc is only trustworthy while that rule holds. + +## What Changed + +`spec/project-types.json`'s `crossCutting.linter-parity` dimension gained `parity.hooks`: a repo +with no local hook mechanism wired at all is now a `linter-parity` defect, the same severity a +missing markdownlint config already gets. A repo mid-convergence, the doc-gate half wired and the +language-format half not yet because its own corpus does not pass it clean, stays operational, per +the existing carve-out in GOVERNANCE.md. Two catalog snippets carry the canonical shape: Husky.Net +(`catalog/snippets/husky/`, for .NET or any project including Python) and the Python `pre-commit` +framework (`catalog/snippets/pre-commit/`, for a repo with no `.husky/` tree). Both now carry the +same shared doc gates, the diff-scoped prose/comment-style gate and the whole-tree line-ending +check, fetched fresh from the hub's `main` branch at run time via `hub-fetch-run.py` rather than +vendored or pinned. + +## Per-Repo Conversion + +Copy and adapt the applicable catalog snippet plus `catalog/snippets/hub-fetch-run.py`, enable it +(`git config core.hooksPath .husky` or `uv run pre-commit install`), confirm the doc gates run +clean against the repo's current tree, and open the PR through the repo's normal branching model. +A repo whose corpus does not yet pass its language formatter clean ships the doc-gate half first +and adds the language half once it does, per the mid-convergence carve-out. That partial state is +not a reason to leave the box unchecked, since the doc-gate half alone already satisfies +`parity.hooks`' intent tier. After merge, check the box below. + +A repo declaring neither `csharp` nor `python` in `registry/repos.json` (the `eda` repos, and any +repo with no fleet-covered language) has no language-format half to add: the doc-gate half alone +is the complete, operational state for that repo, not an incomplete one a later resync should +mistake for unfinished work. + +## Rollout Checklist + +Repos and their current `registry/repos.json` `types`, from the hub's own registry as of this +doc's authorship. Archived repos are not tracked. + +- [ ] **ProjectTemplate** (`source-only`, `docs`): the hub itself, already mid-convergence, doc + gates wired in `.husky/pre-commit` since PR #642. The ruff half stays out until this repo's + own Python corpus passes `ruff format --check` / `ruff check` clean, tracked separately in + `TODO.md`. Checked once that convergence lands, not as part of the downstream sweep below. +- [ ] **Utilities** (`csharp`, `nuget`): Husky.Net + doc gates. +- [ ] **LanguageTags** (`csharp`, `nuget`, `codegen`): Husky.Net + doc gates. +- [ ] **aiopurpleair** (`python`, `pypi`): `pre-commit` framework + doc gates. +- [ ] **homeassistant-purpleair** (`python`, `homeassistant`): `pre-commit` framework + doc gates. +- [ ] **Financial-Modeling** (`python`, `source-only`): `pre-commit` framework + doc gates. +- [ ] **PlexCleaner** (`csharp`, `dotnet-publish`, `docker`, `python`): Husky.Net (with its ruff + block filled in) covers both languages in one hook, rather than wiring two mechanisms. +- [ ] **ESPHome-NonRoot** (`docker`, `upstream-wrapper`): doc-gate half only, no declared language. +- [ ] **VSCode-Server-DotNetCore** (`docker`): doc-gate half only, no declared language. +- [ ] **NxWitness** (`docker`, `upstream-wrapper`, `codegen`, `csharp`): Husky.Net + doc gates. +- [ ] **HomeAutomation-Config** (`source-only`): doc-gate half only, no declared language. +- [ ] **KiCadLibrary** (`eda`): doc-gate half only, no fleet-covered language. +- [ ] **EspDinIoT** (`eda`): doc-gate half only, no fleet-covered language. +- [ ] **ESPHome-Config** (`source-only`, `python`, `cpp`): `pre-commit` framework + doc gates for + the Python half. `cpp` has no fleet linter declared today, out of scope here. +- [ ] **HomeAssistant-Config** (`source-only`): doc-gate half only, no declared language. +- [ ] **DevKitCIoT** (`eda`): doc-gate half only, no fleet-covered language. +- [ ] **PhotoCleaner** (`csharp`, `dotnet-publish`, `docker`): Husky.Net + doc gates. +- [ ] **MediaTools** (`csharp`, `nuget`): Husky.Net + doc gates. +- [ ] **AudioCleaner** (`csharp`, `dotnet-publish`): Husky.Net + doc gates. +- [ ] **Vantage-Config** (`source-only`): doc-gate half only, no declared language. +- [ ] **HolidayLights** (`source-only`): doc-gate half only, no declared language. +- [ ] **Blog** (`hugo`, `source-only`): doc-gate half only, no declared language. + + + +[eol-lf-rollout]: ./eol-lf-rollout.md +[governance-running-the-linters-locally]: ../GOVERNANCE.md#running-the-linters-locally-known-working-invocations +[snippets-husky]: ../catalog/snippets/husky/README.md +[snippets-pre-commit]: ../catalog/snippets/pre-commit/README.md diff --git a/spec/project-types.json b/spec/project-types.json index 0dca70ae..b89dfd16 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -167,7 +167,8 @@ "checks": [ { "id": "parity.markdownlint", "verdict": "letter", "assert": "One .markdownlint-cli2.jsonc drives the VS Code extension, the CLI, and CI, and CI runs it.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" }, { "id": "parity.cspell", "verdict": "letter", "assert": "A cspell.json (not only a workspace word list) drives the extension, the CLI, and CI.", "intentRef": "CODESTYLE.md" }, - { "id": "parity.lang", "verdict": "intent", "assert": "ruff/pyright (Python) and editorconfig/csharpier (C#) configs are shared by editor, CLI, and CI where those languages are present.", "intentRef": "CODESTYLE.md" } + { "id": "parity.lang", "verdict": "intent", "assert": "ruff/pyright (Python) and editorconfig/csharpier (C#) configs are shared by editor, CLI, and CI where those languages are present.", "intentRef": "CODESTYLE.md" }, + { "id": "parity.hooks", "verdict": "intent", "assert": "A local hook mechanism (Husky.Net or the Python pre-commit framework) exists and is wired (core.hooksPath set, or pre-commit install run), and runs at minimum the diff-scoped prose gate and the eol check. The language-formatting half (ruff/pyright, CSharpier/dotnet format) is added once the repo's own corpus passes it clean, a repo mid-convergence rather than a repo out of conformance.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" } ] }, "recurring-violations": { diff --git a/spec/third-party-tools.json b/spec/third-party-tools.json index 2ec9664e..f3bcc3a1 100644 --- a/spec/third-party-tools.json +++ b/spec/third-party-tools.json @@ -32,6 +32,7 @@ { "name": "NEbml", "link": "https://github.com/OlegZee/NEbml", "description": "EBML reader and writer for .NET." }, { "name": "Nerdbank.GitVersioning", "link": "https://github.com/dotnet/Nerdbank.GitVersioning", "description": "Version computation from git height." }, { "name": "PaperMod", "link": "https://github.com/adityatelange/hugo-PaperMod", "description": "Hugo theme." }, + { "name": "pre-commit", "link": "https://pre-commit.com/", "description": "Git hook manager for Python." }, { "name": "Proxmox VE", "link": "https://www.proxmox.com/en/proxmox-virtual-environment/overview", "description": "Virtualization platform." }, { "name": "regex101.com", "link": "https://regex101.com/", "description": "Regular expression tester." }, { "name": "RFC 5646 language tags", "link": "https://www.rfc-editor.org/rfc/rfc5646.html", "description": "Language tag standard." }, From d3e646d4538ff44cbee697014a9a46e57ae85967 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 14:42:55 -0700 Subject: [PATCH 2/8] Fix Round-1 Review Findings on Pre-Commit Hooks PR Restores the shellcheck SC1091 suppression dropped from catalog/snippets/husky/pre-commit during the rewrite, corrects GOVERNANCE.md's network-call count, and trims policy restatement out of CODESTYLE.md and the two SKILL.md files back to a pointer at GOVERNANCE.md's canonical section. Fixes real portability and reliability bugs the review caught: drops an unneeded `from __future__ import annotations` (no forward reference needs it at the 3.13 floor), makes an unborn HEAD's first commit possible by substituting git's empty-tree hash for `--diff HEAD` in hub-fetch-run.py, switches `python3` to `uv run python` / a Windows task override so the doc gates actually run on native Windows, and removes the tool-presence guards on the Husky.Net language blocks so a missing required tool fails the commit loudly instead of silently skipping the check. Declines the remaining findings with evidence in their threads: the mutable main-branch fetch is the maintainer's own explicit design choice from this session, the working-tree diff scope matches the hub's own pre-existing hook verbatim, the rollout doc's change-framing matches its own eol-lf-rollout.md precedent, and one comment-wrap finding no longer matches the committed content per prose_lint.py itself. --- .agents/skills/dotnet-codestyle/SKILL.md | 7 +++-- .agents/skills/python-codestyle/SKILL.md | 5 ++-- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/dotnet-codestyle/SKILL.md | 7 +++-- .../skills/python-codestyle/SKILL.md | 5 ++-- .github/skills/dotnet-codestyle/SKILL.md | 7 +++-- .github/skills/python-codestyle/SKILL.md | 5 ++-- CODESTYLE.md | 2 +- GOVERNANCE.md | 4 +-- .../snippets/configs/vscode-tasks-python.json | 8 +++--- catalog/snippets/configs/vscode-tasks.json | 8 ++++++ catalog/snippets/hub-fetch-run.py | 27 ++++++++++++++++--- catalog/snippets/husky/README.md | 12 ++++++--- catalog/snippets/husky/pre-commit | 18 +++++++------ .../pre-commit/.pre-commit-config.yaml | 4 +-- docs/pre-commit-hooks-rollout.md | 12 +++++---- 16 files changed, 82 insertions(+), 51 deletions(-) diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index de797687..f3565a5c 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -49,10 +49,9 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - A working local hook is strongly suggested, not optional: wire Husky.Net (`dotnet husky run` as - a style step, plus the shared prose/EOL doc gates) from the canonical - `catalog/snippets/husky/` config, since a repo with no hook wired at all is now a measured audit - finding, per GOVERNANCE.md "Running the Linters Locally". + A working local hook is strongly suggested, not optional: wire Husky.Net from the canonical + `catalog/snippets/husky/` config. See GOVERNANCE.md "Running the Linters Locally" for what the + hook must cover and what its absence means. **A new port is not a license to silence diagnostics.** Brownfield or just-ported status never justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index cc09ba09..afb345af 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -92,9 +92,8 @@ documented commands, and an optional VS Code tasks mirror (all `type: process`, chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. CI runs the same clean-compile commands as the authoritative backstop. A working local hook is strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical -`catalog/snippets/pre-commit/.pre-commit-config.yaml` (ruff, the type checker, and the shared -prose/EOL doc gates), since a repo with no hook wired at all is now a measured audit finding, per -GOVERNANCE.md "Running the Linters Locally". +`catalog/snippets/pre-commit/.pre-commit-config.yaml`. See GOVERNANCE.md "Running the Linters +Locally" for what the hook must cover and what its absence means. A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 6b18f619..c378a05e 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -1b671ebb15a74698 +309580363f245493 diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index de797687..f3565a5c 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -49,10 +49,9 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - A working local hook is strongly suggested, not optional: wire Husky.Net (`dotnet husky run` as - a style step, plus the shared prose/EOL doc gates) from the canonical - `catalog/snippets/husky/` config, since a repo with no hook wired at all is now a measured audit - finding, per GOVERNANCE.md "Running the Linters Locally". + A working local hook is strongly suggested, not optional: wire Husky.Net from the canonical + `catalog/snippets/husky/` config. See GOVERNANCE.md "Running the Linters Locally" for what the + hook must cover and what its absence means. **A new port is not a license to silence diagnostics.** Brownfield or just-ported status never justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index cc09ba09..afb345af 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -92,9 +92,8 @@ documented commands, and an optional VS Code tasks mirror (all `type: process`, chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. CI runs the same clean-compile commands as the authoritative backstop. A working local hook is strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical -`catalog/snippets/pre-commit/.pre-commit-config.yaml` (ruff, the type checker, and the shared -prose/EOL doc gates), since a repo with no hook wired at all is now a measured audit finding, per -GOVERNANCE.md "Running the Linters Locally". +`catalog/snippets/pre-commit/.pre-commit-config.yaml`. See GOVERNANCE.md "Running the Linters +Locally" for what the hook must cover and what its absence means. A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index de797687..f3565a5c 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -49,10 +49,9 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - A working local hook is strongly suggested, not optional: wire Husky.Net (`dotnet husky run` as - a style step, plus the shared prose/EOL doc gates) from the canonical - `catalog/snippets/husky/` config, since a repo with no hook wired at all is now a measured audit - finding, per GOVERNANCE.md "Running the Linters Locally". + A working local hook is strongly suggested, not optional: wire Husky.Net from the canonical + `catalog/snippets/husky/` config. See GOVERNANCE.md "Running the Linters Locally" for what the + hook must cover and what its absence means. **A new port is not a license to silence diagnostics.** Brownfield or just-ported status never justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index cc09ba09..afb345af 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -92,9 +92,8 @@ documented commands, and an optional VS Code tasks mirror (all `type: process`, chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. CI runs the same clean-compile commands as the authoritative backstop. A working local hook is strongly suggested, not opt-in: wire the Python `pre-commit` framework from the canonical -`catalog/snippets/pre-commit/.pre-commit-config.yaml` (ruff, the type checker, and the shared -prose/EOL doc gates), since a repo with no hook wired at all is now a measured audit finding, per -GOVERNANCE.md "Running the Linters Locally". +`catalog/snippets/pre-commit/.pre-commit-config.yaml`. See GOVERNANCE.md "Running the Linters +Locally" for what the hook must cover and what its absence means. A restricted executor gives each task a cache directory under a writable temporary root. Point `UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before diff --git a/CODESTYLE.md b/CODESTYLE.md index 7a7badc7..f52773d7 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -18,7 +18,7 @@ Each language defines a **clean-compile** verification: the combination of build - **Run it after every code change, and it is not the whole gate.** The relevant language's clean-compile must pass before you commit. CI runs those same language checks as a backstop **plus everything else its validation workflow runs**, and all of it reports into the one required status, so a green clean-compile does not predict a green CI. That remainder is at least the doc-lint set (markdownlint, cspell, actionlint, `editorconfig-checker`) and whatever spec, config, and script gates the repo carries, so read the workflow for the full list rather than assuming this sentence enumerates it. What has to pass before a push is the repo's **whole** lint gate, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. Each linter's known-working invocation is in [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally]. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly, and either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). -- **A working local commit/pre-commit gate is strongly suggested, and the audit now measures it.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so the *mechanism* choice is the repo's: Husky.Net (and `dotnet husky run` as a style step) for .NET, `pre-commit` for Python, canonical configs for both in `catalog/snippets/`. CI is still the authoritative backstop, but a repo with no hook wired at all is a `linter-parity` defect, not an invisible gap, per [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally]. A repo mid-convergence (the doc-gate half wired, the language-format half not yet, because its own corpus does not pass it clean) stays operational rather than out of conformance. Keeping a working gate is not drift. +- **A working local commit/pre-commit gate is strongly suggested, not the repo's free choice to skip.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so the *mechanism* stays the repo's choice: Husky.Net for .NET, `pre-commit` for Python, canonical configs for both in `catalog/snippets/`. What that gate must cover, and what its absence means for the audit, is [GOVERNANCE.md "Running the Linters Locally"][governance-running-the-linters-locally], not restated here. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 5772c1df..df0ded12 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -219,9 +219,9 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): - **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. -- **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. +- **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. - **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. -- **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. This is the one network call the hook makes, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or `uv run pre-commit install`, and CI remains the authoritative run either way. +- **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the hook makes, one per fetched script, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or `uv run pre-commit install`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. The Docker invocations below run the same tools and configs as the VS Code tasks. Their headless form separates the image pull and minimizes repository exposure for an agent executor. diff --git a/catalog/snippets/configs/vscode-tasks-python.json b/catalog/snippets/configs/vscode-tasks-python.json index e35e6c13..2bd6a4fb 100644 --- a/catalog/snippets/configs/vscode-tasks-python.json +++ b/catalog/snippets/configs/vscode-tasks-python.json @@ -174,8 +174,8 @@ { "label": "Lint: Prose", "type": "process", - "command": "python3", - "args": [ "${workspaceFolder}/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], + "command": "uv", + "args": [ "run", "python", "${workspaceFolder}/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], "problemMatcher": [], "presentation": { "showReuseMessage": false, @@ -185,8 +185,8 @@ { "label": "Lint: EOL", "type": "process", - "command": "python3", - "args": [ "${workspaceFolder}/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], + "command": "uv", + "args": [ "run", "python", "${workspaceFolder}/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], "problemMatcher": [], "presentation": { "showReuseMessage": false, diff --git a/catalog/snippets/configs/vscode-tasks.json b/catalog/snippets/configs/vscode-tasks.json index 004b453a..f1d1d6de 100644 --- a/catalog/snippets/configs/vscode-tasks.json +++ b/catalog/snippets/configs/vscode-tasks.json @@ -175,6 +175,10 @@ "type": "process", "command": "python3", "args": [ "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], + "windows": { + "command": "py", + "args": [ "-3", "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ] + }, "problemMatcher": [], "presentation": { "showReuseMessage": false, @@ -186,6 +190,10 @@ "type": "process", "command": "python3", "args": [ "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], + "windows": { + "command": "py", + "args": [ "-3", "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ] + }, "problemMatcher": [], "presentation": { "showReuseMessage": false, diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub-fetch-run.py index e62d0da8..8f094fbc 100644 --- a/catalog/snippets/hub-fetch-run.py +++ b/catalog/snippets/hub-fetch-run.py @@ -8,9 +8,8 @@ Example: hub-fetch-run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD """ -from __future__ import annotations - import runpy +import subprocess import sys import tempfile import urllib.error @@ -18,13 +17,35 @@ from pathlib import Path HUB_RAW_BASE = "https://raw.githubusercontent.com/ptr727/ProjectTemplate/main" +EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + + +def resolve_unborn_head(argv: list[str]) -> list[str]: + """Replace a `--diff HEAD` pair with git's empty-tree hash when HEAD does not exist yet. + + A brand-new repository's first commit has no HEAD to diff against, and the fetched gate + scripts refuse to widen to a whole-tree scan rather than report the backlog as new. The + empty tree diffs cleanly against everything staged, which is the correct scope for a first + commit. Any other `--diff` value passes through unchanged. + """ + out = list(argv) + for i, token in enumerate(out[:-1]): + if token == "--diff" and out[i + 1] == "HEAD": + probe = subprocess.run( + ["git", "rev-parse", "--verify", "-q", "HEAD"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if probe.returncode != 0: + out[i + 1] = EMPTY_TREE + return out def main(argv: list[str]) -> int: if not argv: print("hub-fetch-run: usage: hub-fetch-run.py [script-args...]", file=sys.stderr) return 2 - hub_path, script_args = argv[0], argv[1:] + hub_path, script_args = argv[0], resolve_unborn_head(argv[1:]) url = f"{HUB_RAW_BASE}/{hub_path}" try: with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 - fixed https host diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index 1907f787..f7f74e58 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -8,8 +8,8 @@ and the whole-tree line-ending check. Copy `../hub-fetch-run.py` alongside `pre- the doc gates to run: it fetches those two checks fresh from `ptr727/ProjectTemplate`'s `main` branch and runs them, rather than vendoring or pinning a copy. A pin nothing keeps current goes stale by construction, and CI (this repo's own, and the hub's) is the backstop for a -change that lands broken on `main` before it does real damage locally. This is one more -network fetch alongside the Docker pulls the Lint tasks below already do. A fetch failure +change that lands broken on `main` before it does real damage locally. These are two more +network fetches alongside the Docker pulls the Lint tasks below already do. A fetch failure fails the commit rather than silently skipping the gate. Full linting (workflow YAML, Markdown, spelling, EditorConfig) is **not** run in the hook. It @@ -22,5 +22,9 @@ keeps it fast and offline-safe. A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in `.gitattributes` (`.husky/pre-commit text eol=lf`), git-level enforcement independent of the editor. The fleet's `[*]` `.editorconfig` default already gives it LF, no path-specific -override needed. A CRLF shebang breaks execution. Drop the `dotnet husky run` line in a -non-.NET repo, and drop the ruff block in a non-Python repo. +override needed. A CRLF shebang breaks execution. + +Both language blocks run unconditionally, with no tool-presence guard: a repo that keeps a +block declares that tool required, so a missing one fails the commit loudly rather than +skipping the check silently. Drop the `dotnet husky run` block in a non-.NET repo, and drop +the ruff block in a non-Python repo, rather than leaving it in place to no-op. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index cb484a09..88da458e 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -1,4 +1,5 @@ #!/bin/sh +# shellcheck disable=SC1091 # Generated by Husky install, not present at lint time. . "$(dirname "$0")/_/husky.sh" # Local pre-commit: language formatting/lint, plus the fleet's shared doc gates. @@ -8,16 +9,14 @@ # Full doc-lint set (Markdown, spelling, workflow YAML, EditorConfig) stays in CI and the VS Code Lint tasks, since those need Docker. # .NET: CSharpier + dotnet format style via Husky.Net. -if command -v dotnet >/dev/null 2>&1; then - dotnet husky run || exit 1 -fi +# Drop this block in a non-.NET repo, rather than guarding it. +# A repo that keeps it declares dotnet required, and a missing tool fails the commit loudly, not silently. +dotnet husky run || exit 1 # Python: ruff format + ruff check via uv, native tooling. -# Drop this block in a non-Python repo. -if command -v uv >/dev/null 2>&1; then - uv run ruff format --check || exit 1 - uv run ruff check || exit 1 -fi +# Drop this block in a non-Python repo, for the same reason the .NET block above is unguarded. +uv run ruff format --check || exit 1 +uv run ruff check || exit 1 # The interpreter is chosen by probing, not assumed, mirroring spec/host-tools.json's own probe order. # On native Windows the python.org install registers `py`, not `python3`, and a `python3` found on PATH there is often the Microsoft Store alias stub, present but broken. @@ -30,5 +29,8 @@ else exit 1 fi +# The prose gate scopes to the working tree against HEAD, not only the staged index. +# A partially staged file is judged on all of its edits, not only the staged ones. +# CI re-checks the whole tree regardless, which is what makes that scope affordable in a hook. run_py "$(dirname "$0")/hub-fetch-run.py" .github/actions/prose-gate/prose_lint.py . --diff HEAD || exit 1 run_py "$(dirname "$0")/hub-fetch-run.py" .github/actions/repo-gate/repo_gate.py --check eol || exit 1 diff --git a/catalog/snippets/pre-commit/.pre-commit-config.yaml b/catalog/snippets/pre-commit/.pre-commit-config.yaml index d014ccb2..b85bf7c6 100644 --- a/catalog/snippets/pre-commit/.pre-commit-config.yaml +++ b/catalog/snippets/pre-commit/.pre-commit-config.yaml @@ -26,11 +26,11 @@ repos: pass_filenames: false - id: prose-gate name: prose/comment-style gate (diff-scoped, fetched fresh from ProjectTemplate@main) - entry: uv run python3 hub-fetch-run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD + entry: uv run python hub-fetch-run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD language: system pass_filenames: false - id: eol-gate name: line-ending consistency gate (fetched fresh from ProjectTemplate@main) - entry: uv run python3 hub-fetch-run.py .github/actions/repo-gate/repo_gate.py --check eol + entry: uv run python hub-fetch-run.py .github/actions/repo-gate/repo_gate.py --check eol language: system pass_filenames: false diff --git a/docs/pre-commit-hooks-rollout.md b/docs/pre-commit-hooks-rollout.md index 00e0e742..709f5795 100644 --- a/docs/pre-commit-hooks-rollout.md +++ b/docs/pre-commit-hooks-rollout.md @@ -15,11 +15,13 @@ doc is only trustworthy while that rule holds. ## What Changed -`spec/project-types.json`'s `crossCutting.linter-parity` dimension gained `parity.hooks`: a repo -with no local hook mechanism wired at all is now a `linter-parity` defect, the same severity a -missing markdownlint config already gets. A repo mid-convergence, the doc-gate half wired and the -language-format half not yet because its own corpus does not pass it clean, stays operational, per -the existing carve-out in GOVERNANCE.md. Two catalog snippets carry the canonical shape: Husky.Net +`spec/project-types.json`'s `crossCutting.linter-parity` dimension gained `parity.hooks`, judged +by hand during an `AUDIT.md` run like every sibling check in that dimension, never by +`spec/audit.py` itself: a repo with no local hook mechanism wired at all is a `linter-parity` +defect, the same severity a missing markdownlint config already gets. A repo mid-convergence, the +doc-gate half wired and the language-format half not yet because its own corpus does not pass it +clean, stays operational, per the existing carve-out in GOVERNANCE.md. Two catalog snippets carry +the canonical shape: Husky.Net (`catalog/snippets/husky/`, for .NET or any project including Python) and the Python `pre-commit` framework (`catalog/snippets/pre-commit/`, for a repo with no `.husky/` tree). Both now carry the same shared doc gates, the diff-scoped prose/comment-style gate and the whole-tree line-ending From 73157b0b99bbc024ace88f52381ef5aed2207bb2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 14:47:25 -0700 Subject: [PATCH 3/8] Fix Round-2 Review Findings on Pre-Commit Hooks PR CodeRabbit's fresh pass on the round-1 fix caught two more real gaps. The Husky.Net Python block ran ruff but no type checker, unlike the pre-commit framework snippet, so adds a uv run pyright step (swap for mypy per the repo's CI checker), matching the parity the other mechanism already had. The parity.hooks assertion judged evidence the audit cannot actually see: core.hooksPath and pre-commit install are per-clone state, not something a repo read observes. Rewrites the assertion to judge the tracked hook config file's content directly, which is the evidence the audit can actually read. --- GOVERNANCE.md | 2 +- catalog/snippets/husky/README.md | 9 +++++---- catalog/snippets/husky/pre-commit | 4 +++- spec/project-types.json | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index df0ded12..f25f5f5e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -220,7 +220,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i - **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. - **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. -- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. +- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. - **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the hook makes, one per fetched script, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or `uv run pre-commit install`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index f7f74e58..9d7ba542 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -2,9 +2,10 @@ `pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting/lint and the fleet's shared doc gates**: CSharpier and -`dotnet format` style via `dotnet husky run` for .NET, or `ruff format --check` / `ruff check` -for a Python repo (native tooling, no Docker), plus the diff-scoped prose/comment-style gate -and the whole-tree line-ending check. Copy `../hub-fetch-run.py` alongside `pre-commit` for +`dotnet format` style via `dotnet husky run` for .NET, or `ruff format --check` / `ruff check` / +the repo's type checker for a Python repo (native tooling, no Docker, the same checks the +`../pre-commit/.pre-commit-config.yaml` snippet runs), plus the diff-scoped prose/comment-style +gate and the whole-tree line-ending check. Copy `../hub-fetch-run.py` alongside `pre-commit` for the doc gates to run: it fetches those two checks fresh from `ptr727/ProjectTemplate`'s `main` branch and runs them, rather than vendoring or pinning a copy. A pin nothing keeps current goes stale by construction, and CI (this repo's own, and the hub's) is the backstop for a @@ -27,4 +28,4 @@ override needed. A CRLF shebang breaks execution. Both language blocks run unconditionally, with no tool-presence guard: a repo that keeps a block declares that tool required, so a missing one fails the commit loudly rather than skipping the check silently. Drop the `dotnet husky run` block in a non-.NET repo, and drop -the ruff block in a non-Python repo, rather than leaving it in place to no-op. +the Python block in a non-Python repo, rather than leaving it in place to no-op. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index 88da458e..e72cbf71 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -13,10 +13,12 @@ # A repo that keeps it declares dotnet required, and a missing tool fails the commit loudly, not silently. dotnet husky run || exit 1 -# Python: ruff format + ruff check via uv, native tooling. +# Python: ruff format + ruff check + the repo's type checker via uv, native tooling. # Drop this block in a non-Python repo, for the same reason the .NET block above is unguarded. uv run ruff format --check || exit 1 uv run ruff check || exit 1 +# Swap for `uv run mypy` if this repo's CI runs mypy instead. +uv run pyright || exit 1 # The interpreter is chosen by probing, not assumed, mirroring spec/host-tools.json's own probe order. # On native Windows the python.org install registers `py`, not `python3`, and a `python3` found on PATH there is often the Microsoft Store alias stub, present but broken. diff --git a/spec/project-types.json b/spec/project-types.json index b89dfd16..06ebb9cc 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -168,7 +168,7 @@ { "id": "parity.markdownlint", "verdict": "letter", "assert": "One .markdownlint-cli2.jsonc drives the VS Code extension, the CLI, and CI, and CI runs it.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" }, { "id": "parity.cspell", "verdict": "letter", "assert": "A cspell.json (not only a workspace word list) drives the extension, the CLI, and CI.", "intentRef": "CODESTYLE.md" }, { "id": "parity.lang", "verdict": "intent", "assert": "ruff/pyright (Python) and editorconfig/csharpier (C#) configs are shared by editor, CLI, and CI where those languages are present.", "intentRef": "CODESTYLE.md" }, - { "id": "parity.hooks", "verdict": "intent", "assert": "A local hook mechanism (Husky.Net or the Python pre-commit framework) exists and is wired (core.hooksPath set, or pre-commit install run), and runs at minimum the diff-scoped prose gate and the eol check. The language-formatting half (ruff/pyright, CSharpier/dotnet format) is added once the repo's own corpus passes it clean, a repo mid-convergence rather than a repo out of conformance.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" } + { "id": "parity.hooks", "verdict": "intent", "assert": "A local hook config file exists in the repo's tracked tree (.husky/pre-commit or .pre-commit-config.yaml), and its content, read directly rather than inferred from per-clone core.hooksPath or pre-commit install state neither of which the audit can observe, invokes at minimum the diff-scoped prose gate and the eol check via hub-fetch-run.py. The language-formatting half (ruff/pyright or mypy, CSharpier/dotnet format) is added once the repo's own corpus passes it clean, a repo mid-convergence rather than a repo out of conformance.", "intentRef": "GOVERNANCE.md#running-the-linters-locally-known-working-invocations" } ] }, "recurring-violations": { From a6ed498581cc7142041a34aaa2be50b86f5791aa Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 14:49:59 -0700 Subject: [PATCH 4/8] Correct Offline Claim in Husky Snippet README The doc gates need network to reach hub-fetch-run.py's two fetches, so the hook is not offline-safe, only Docker-free. Clarifies the claim and states the actual offline failure mode: a loud commit-blocking fetch error, never a silent skip. --- catalog/snippets/husky/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index 9d7ba542..6f347f3d 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -18,7 +18,9 @@ runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tas `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`), which also carries the same prose/EOL gates in whole-repo mode for on-demand full-tree validation, not just the diff-scoped commit-time run. Keeping the Docker-dependent doc linters out of the hook is what -keeps it fast and offline-safe. +keeps it fast, and Docker is the dependency it stays free of: the hook still needs network +for the two `hub-fetch-run.py` calls above, so a fully offline clone fails the commit loudly +on the fetch rather than silently skipping the doc gates. A copied `.husky/pre-commit` is an extensionless shebang script, so pin it to **LF** in `.gitattributes` (`.husky/pre-commit text eol=lf`), git-level enforcement independent of the From 047f7013338fa2631cf97bdca74e92af3ff533ed Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 14:51:38 -0700 Subject: [PATCH 5/8] Cd to Repo Toplevel in the Husky Snippet's Doc Gates The snippet passed a relative scan root to the fetched gates with no cd, unlike the hub's own .husky/pre-commit, so a manual invocation from a subdirectory would scan that subdirectory instead of the repository root and report a false clean result. Adds the same defensive cd the hub's own hook already carries, capturing hub-fetch-run.py's absolute path first since $0 resolves against the original directory. Verified against a real subdirectory invocation. --- catalog/snippets/husky/pre-commit | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index e72cbf71..ba07fc01 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -8,6 +8,13 @@ # See hub-fetch-run.py's own docstring, and catalog/snippets/husky/README.md, for why. # Full doc-lint set (Markdown, spelling, workflow YAML, EditorConfig) stays in CI and the VS Code Lint tasks, since those need Docker. +# The absolute path is captured before the cd below, since $0 resolves against the original directory. +hub_fetch_run="$(cd "$(dirname "$0")" && pwd)/hub-fetch-run.py" + +# Git already runs a hook from the top level, but this is belt and braces for an invocation that does not. +# The doc gates below take a relative scan root, which would otherwise resolve against whatever directory the caller stood in. +cd "$(git rev-parse --show-toplevel)" || exit 1 + # .NET: CSharpier + dotnet format style via Husky.Net. # Drop this block in a non-.NET repo, rather than guarding it. # A repo that keeps it declares dotnet required, and a missing tool fails the commit loudly, not silently. @@ -34,5 +41,5 @@ fi # The prose gate scopes to the working tree against HEAD, not only the staged index. # A partially staged file is judged on all of its edits, not only the staged ones. # CI re-checks the whole tree regardless, which is what makes that scope affordable in a hook. -run_py "$(dirname "$0")/hub-fetch-run.py" .github/actions/prose-gate/prose_lint.py . --diff HEAD || exit 1 -run_py "$(dirname "$0")/hub-fetch-run.py" .github/actions/repo-gate/repo_gate.py --check eol || exit 1 +run_py "$hub_fetch_run" .github/actions/prose-gate/prose_lint.py . --diff HEAD || exit 1 +run_py "$hub_fetch_run" .github/actions/repo-gate/repo_gate.py --check eol || exit 1 From bd06fe1bf83c05bf9b76cdba093bfbc240a52be3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 14:54:30 -0700 Subject: [PATCH 6/8] Correct Prose-Gate Scope Description in GOVERNANCE.md The canonical hook runs prose_lint.py with --diff HEAD, which scopes to the working tree diff, including unstaged edits, not just what the commit changes as the sentence claimed. Reworded to match the actual scope, and folded in the working-tree-vs-index rationale already documented in the husky snippet's own comment so both say the same thing. --- GOVERNANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index f25f5f5e..4de8069e 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -220,7 +220,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i - **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. - **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. -- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. +- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to the working tree diff against `HEAD` rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. That scope is the working tree rather than the staged index, so a partially staged file is judged on all of its edits, not only the staged ones, and CI re-checks the whole tree regardless, which is what makes that scope affordable in a hook. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. - **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the hook makes, one per fetched script, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or `uv run pre-commit install`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. From f2422467f069d9771c78ac8043500c6ce6266b98 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 15:09:19 -0700 Subject: [PATCH 7/8] Fix CI Lint Failures and Complete the Hub's Own Hook CI's whole-repo ruff pass caught 3 real issues in the new hub-fetch-run.py: a decorative shebang on a file never executed directly, subprocess.run with no explicit check argument, and an unused noqa. Fixes all three and confirms clean locally (ruff check, ruff format, pyright). That CI run also surfaced that this repo's own Python corpus already passes ruff and mypy clean, contradicting the stale narrative in .husky/pre-commit's own comments and a TODO.md entry, both measured back in early August. Verified directly against origin/develop: 0 ruff errors, 200 files already formatted, mypy clean on all 27 source files. The one blocker the hub's own hook was waiting on is gone, so completes it: adds the ruff/mypy steps via uvx, matching this repo's own CI invocation for a repo carrying no uv.lock. Removes the now-stale TODO.md entry and updates this PR's own rollout doc to check the ProjectTemplate row. --- .husky/pre-commit | 11 ++++++----- TODO.md | 1 - catalog/snippets/hub-fetch-run.py | 9 ++++++--- docs/pre-commit-hooks-rollout.md | 7 +++---- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 53028209..fe676efb 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -7,11 +7,7 @@ # Sourcing it would therefore break the hook in a fresh clone. # The path is kept for the fleet convention the line-ending pins are written against. # -# The language-formatting half the fleet convention names is absent here, and measured rather than assumed. -# This repository declares ruff in `pyproject.toml`, no workflow runs it, and the tree does not pass it. -# `ruff format --check` reports 13 of 57 files would be reformatted and `ruff check` reports 106 errors. -# A gate failing on the corpus it guards blocks every commit from the moment it lands. -# Converging the Python comes first, and the step is added here after that rather than before it. +# The language-formatting half the fleet convention names runs via uvx, matching CI's own invocation for a repo with no `uv.lock` (the Scripts profile, CODESTYLE.md "Two profiles"). # # `repo_gate.py --check sha-pin` is absent for a different reason. # It resolves same-owner pins against the GitHub API, and a hook needing a network fails offline. @@ -23,6 +19,11 @@ set -e # The relative paths below would otherwise resolve against whatever directory the caller was in. cd "$(git rev-parse --show-toplevel)" +# Ruff and mypy, via uvx per this repo's own CI invocation for a repo with no `uv.lock`. +uvx ruff@latest format --check . +uvx ruff@latest check . +uvx mypy@latest + # The interpreter is chosen by running the probes spec/host-tools.json declares, in its order. # On native Windows the python.org install registers `py` and not `python3`. # That name resolves to a Microsoft Store alias stub, and Git Bash inherits the Windows PATH. diff --git a/TODO.md b/TODO.md index 95de391b..61d3a208 100644 --- a/TODO.md +++ b/TODO.md @@ -408,7 +408,6 @@ Small work with no research to preserve, selectable one bullet at a time. - **Answer the symmetric reading of [`.editorconfig`][editorconfig], a path-specific section naming files that do not exist**, which is the half of [#633][issue-633] the `eol-coverage` check deliberately left open. The dead-pin reading it does ship is the `.gitattributes` side, and the same question on the other document is not the same shape: this repo's `[.github/workflows/*]` and `[catalog/snippets/workflows/*]` sections are legitimately broad, and the issue's own first attempt at it produced false positives because the matcher did not expand brace syntax, which [`scripts/repo_gate.py`][repo-gate] already implements. Measure the exemption against the live corpus before building the gate rather than after, since a stale exemption hands out a work list that damages correct documents, and decide whether `forward-declared` carries across or whether an editorconfig section needs its own marker. - **Audit the fleet's shell surface by size and branching, and decide per script whether Python with unit tests is cheaper.** The evidence is the review record rather than a language preference, since a non-trivial shell script earns findings round after round while every gate under [`scripts/`][scripts] carries a test file under `scripts/tests/` and converges in one or two. The measure is lines, branch count, and the review rounds each has cost. `repo-config/configure.sh` and the agent-safety installer are the two worth measuring, and a bootstrap script that needs the Python it exists to install is not a rewrite worth having, which protects the installer more than the config script. - **Make a table of contents standard for a long document rather than for the README alone.** [`spec/readme-structure.md`][readme-structure] fixes one at README position 4 and no other hub file carries one, which leaves the three longest documents without it, `CODESTYLE.md` at 516 lines, `GOVERNANCE.md` at 436 and `WORKFLOW.md` at 301, measured on `develop` at `3d1a0b1` on 2026-08-06. Settle the threshold in headings or lines so the audit can check it, and settle how it sits with the reference-link exception, since the four agent-instruction files keep inline links exactly because they are read one section at a time, which is the property that makes a contents list worth having in them. The mechanical constraint is that the list is filled by the Markdown All in One extension on save, so a file nobody opens in the editor grows a stale list, which is worse than absent because it is read as current. -- **Converge this repo's Python on the ruff configuration it already declares, then add the formatting half to the pre-commit hook.** `pyproject.toml` carries `[tool.ruff]` and [`spec/project-types.json`][project-types] declares `python.ruff.config`, yet no workflow runs ruff and the tree does not pass it, measured on `develop` at `6d020b1` on 2026-08-09 with ruff 0.16.2: `ruff format --check` reports 13 of 57 files would be reformatted, and `ruff check` reports 106 errors, of which 39 are auto-fixable. The largest groups are 24 `PLW1510` (a `subprocess.run` with no `check`), 17 `FURB167` (`re.M` for `re.MULTILINE`), 11 `EXE001` (a shebang on a non-executable file, which wants reading against the `eol-coverage` shebang set rather than fixed blindly), 9 `BLE001` and 9 `SIM117`. The hook deliberately ships without the ruff step for this reason, since a gate failing on the corpus it guards blocks every commit from the moment it lands, which is the measure-the-corpus-first rule applied to a gate rather than to an exemption. Decide whether CI gains a ruff job in the same pass, since a formatter enforced only by a hook is enforced only on the machines that enabled it. - **Adopt the OCI annotation keys for Docker image metadata across the Docker repos**, replacing the ad-hoc and label-schema keys, per [#363][issue-363]. - **Sweep the central package-version property to `Directory.Packages.props` fleet-wide**, since PlexCleaner sets it in `Directory.Build.props`, off the [`CODESTYLE.md`][codestyle] canonical. - **Canonicalize Python linter-config placement on `pyproject.toml`**, since one cataloged repo uses a standalone ruff config plus a pyright config. Track it as a drift finding and fix it downstream. diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub-fetch-run.py index 8f094fbc..135b22cb 100644 --- a/catalog/snippets/hub-fetch-run.py +++ b/catalog/snippets/hub-fetch-run.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Fetch a ptr727/ProjectTemplate script fresh from `main` and run it in-process. Never pinned or vendored: a pin nothing keeps current goes stale by construction, and CI @@ -35,6 +34,7 @@ def resolve_unborn_head(argv: list[str]) -> list[str]: ["git", "rev-parse", "--verify", "-q", "HEAD"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, ) if probe.returncode != 0: out[i + 1] = EMPTY_TREE @@ -43,12 +43,15 @@ def resolve_unborn_head(argv: list[str]) -> list[str]: def main(argv: list[str]) -> int: if not argv: - print("hub-fetch-run: usage: hub-fetch-run.py [script-args...]", file=sys.stderr) + print( + "hub-fetch-run: usage: hub-fetch-run.py [script-args...]", + file=sys.stderr, + ) return 2 hub_path, script_args = argv[0], resolve_unborn_head(argv[1:]) url = f"{HUB_RAW_BASE}/{hub_path}" try: - with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 - fixed https host + with urllib.request.urlopen(url, timeout=30) as response: content = response.read() except (urllib.error.URLError, OSError) as exc: print(f"hub-fetch-run: could not fetch {url}: {exc}", file=sys.stderr) diff --git a/docs/pre-commit-hooks-rollout.md b/docs/pre-commit-hooks-rollout.md index 709f5795..d2a3c012 100644 --- a/docs/pre-commit-hooks-rollout.md +++ b/docs/pre-commit-hooks-rollout.md @@ -48,10 +48,9 @@ mistake for unfinished work. Repos and their current `registry/repos.json` `types`, from the hub's own registry as of this doc's authorship. Archived repos are not tracked. -- [ ] **ProjectTemplate** (`source-only`, `docs`): the hub itself, already mid-convergence, doc - gates wired in `.husky/pre-commit` since PR #642. The ruff half stays out until this repo's - own Python corpus passes `ruff format --check` / `ruff check` clean, tracked separately in - `TODO.md`. Checked once that convergence lands, not as part of the downstream sweep below. +- [x] **ProjectTemplate** (`source-only`, `docs`): the hub itself. Doc gates wired in + `.husky/pre-commit` since PR #642, and the ruff/mypy half added in this PR's own commits, + once the corpus was confirmed clean (0 ruff errors, 200 files formatted, mypy clean). - [ ] **Utilities** (`csharp`, `nuget`): Husky.Net + doc gates. - [ ] **LanguageTags** (`csharp`, `nuget`, `codegen`): Husky.Net + doc gates. - [ ] **aiopurpleair** (`python`, `pypi`): `pre-commit` framework + doc gates. From 2e4d0ee9ec17571bf39463f019684c5506731628 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 23 Aug 2026 17:20:55 -0700 Subject: [PATCH 8/8] Distinguish Confirmed Unborn HEAD from Any Other Git Probe Failure resolve_unborn_head treated every non-zero git rev-parse exit as unborn, so a non-git directory, a missing git executable, or a permission error all silently substituted the empty-tree hash instead of failing loudly. Verified empirically: a confirmed unborn HEAD is exit 1 with empty stderr, while a genuine error carries a distinct exit code (128) and a fatal message even with -q. Splits the probe into its own function that checks for that exact signature and fails loudly with the actual error on anything else, including a missing git executable (OSError). Verified against all three cases: unborn HEAD, a normal HEAD, and a non-git directory. --- catalog/snippets/hub-fetch-run.py | 41 ++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub-fetch-run.py index 135b22cb..672ddd15 100644 --- a/catalog/snippets/hub-fetch-run.py +++ b/catalog/snippets/hub-fetch-run.py @@ -19,6 +19,36 @@ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" +def head_is_unborn() -> bool: + """Whether HEAD is confirmed unborn (a real branch, no commits yet), never guessed. + + `git rev-parse --verify -q HEAD` exits 1 with empty stderr for a confirmed unborn HEAD, + verified directly: a fresh `git init` with no commits gives exactly that signature. Any + other shape, a non-git directory (exit 128, a `fatal:` message even with `-q`), a missing + or broken git executable (raised as OSError), a permission error, or any other failure, is + a probe failure to propagate, never a reason to guess at the diff scope. + """ + try: + probe = subprocess.run( + ["git", "rev-parse", "--verify", "-q", "HEAD"], + capture_output=True, + check=False, + ) + except OSError as exc: + print(f"hub-fetch-run: could not run git to probe HEAD: {exc}", file=sys.stderr) + sys.exit(1) + if probe.returncode == 1 and not probe.stderr: + return True + if probe.returncode == 0: + return False + print( + f"hub-fetch-run: git rev-parse --verify -q HEAD failed unexpectedly " + f"(exit {probe.returncode}): {probe.stderr.decode(errors='replace').strip()}", + file=sys.stderr, + ) + sys.exit(1) + + def resolve_unborn_head(argv: list[str]) -> list[str]: """Replace a `--diff HEAD` pair with git's empty-tree hash when HEAD does not exist yet. @@ -29,15 +59,8 @@ def resolve_unborn_head(argv: list[str]) -> list[str]: """ out = list(argv) for i, token in enumerate(out[:-1]): - if token == "--diff" and out[i + 1] == "HEAD": - probe = subprocess.run( - ["git", "rev-parse", "--verify", "-q", "HEAD"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if probe.returncode != 0: - out[i + 1] = EMPTY_TREE + if token == "--diff" and out[i + 1] == "HEAD" and head_is_unborn(): + out[i + 1] = EMPTY_TREE return out