From 450399ec03e6c4d8408012bee21363d3f8cfc6d1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 08:55:46 -0700 Subject: [PATCH 1/8] Close the Tagline Question and Give the About Hop Its Own Number (#640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog bookkeeping for a question that is settled, and for a number that was doing two jobs. #577 asked that the tagline be stated as a distinct thing from the intro, so a README may carry a second paragraph without the 100-character cap or the mirrors reaching it. That is what shipped in `c2ce145` (#632): `spec/readme-structure.md` item 1 states that further paragraphs below the tagline are free prose read by no mirror, `CODESTYLE.md` item 4 narrows the `HISTORY.md` mirror to the tagline alone, and `spec/audit.py` extracts the first line of the intro region under a self-test that names the two-paragraph case. #577 is closed against that evidence. Grepping the number before closing it found it in a second place. "The Declared Repository Description" had given #577 to its README-to-About hop entry, which is work the issue's body never covers, so closing the issue on its own correct evidence would have left that entry pointing at a closed number. The hop now carries #639 of its own and the entry cites it. The "Recorded for the Maintainer" bullet that held #577 behind the declared description is deleted rather than rewritten, because the action it recorded is taken. Its reasoning did not survive the check either: the extraction rule it feared writing already existed for the About and Docker Hub mirrors, so narrowing the changelog mirror to match it was one line rather than a new parser, which is recorded on the closing comment so it is not re-derived. ## Verification `scripts/prose_lint.py`, `scripts/repo_gate.py` (`eol`, `eol-coverage`, `sha-pin`), `editorconfig-checker` and `markdownlint-cli2` all pass on the working tree. The file is CRLF and stayed CRLF, which `editorconfig-checker` is the gate that would have caught. Closes #577 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- TODO.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index afec764e..e15382e0 100644 --- a/TODO.md +++ b/TODO.md @@ -56,7 +56,7 @@ One pull request moving the canonical short description into declared data, so e - **Close the README-to-About hop, which is the only one nothing writes.** The audit reports a drifted About panel, and no tool sets it. - **Blocked by** - The entry above, since the field is what `repo-config/configure.sh` would set the panel from. - - **Issue** - [#577][issue-577], whose tagline half shipped on 2026-08-08. + - **Issue** - [#639][issue-639], filed on 2026-08-09 because this entry had been carrying [#577][issue-577], whose body covers only the README tagline and never mentions the About panel, and whose tagline half shipped on 2026-08-08. - **Checked** - `develop` on 2026-08-08, where `repo-config/configure.sh` sets every other repository setting and carries no `description` handling, and [`catalog/snippets/workflows/publish-docker-readme-task.yml`][workflows] pushes `github.event.repository.description` to Docker Hub. - **Open** - Nothing beyond sequencing. - **Settled** - The chain is README, then the About panel by hand, then Docker Hub by CI, so the unautomated hop is the first one and it is the one that drifts. PhotoCleaner is the worked case, where the About panel still matched the README and only the Docker Hub short description had diverged. @@ -460,7 +460,6 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w Actions on issues that are the maintainer's to take, each carrying its evidence so it is one action rather than a re-derivation. - **Re-scope [#305][issue-305] to the push half, and make it the tracking issue for the fleet re-vendor sweep.** Most of what it asked for is built, since the fidelity model, the [`spec/files.json`][files] manifest, [`spec/divergences.json`][divergences] with its generated [reports/divergences.md][divergences-report], and [`AUDIT.md`][audit-doc] section 10 together give the canonical-versus-adapted split and the audit path it proposed. What is genuinely still missing is the push half, since every one of those detects drift while the sweep that fixes it is manual. Re-scoped, it carries the "Fleet Sweeps" visit manifest and Blog as the pilot. Closing it against the built machinery is the alternative, and it loses the only tracking issue the sweep would have. -- **Comment on [#577][issue-577] that it is decided together with the declared description.** Declaring the field in [`registry/repos.json`][repos] makes every mirror read a field rather than parse a paragraph, so taking [#577][issue-577] first means writing an extraction rule the registry change then deletes. ## Verified Complete, Awaiting Close @@ -498,6 +497,7 @@ Each was checked against the tree and has nothing left to do anywhere. Closing i [issue-607]: https://github.com/ptr727/ProjectTemplate/issues/607 [issue-623]: https://github.com/ptr727/ProjectTemplate/issues/623 [issue-633]: https://github.com/ptr727/ProjectTemplate/issues/633 +[issue-639]: https://github.com/ptr727/ProjectTemplate/issues/639 From c2614056d6128d45a320e7ad6b9937c68a56dc16 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 09:29:03 -0700 Subject: [PATCH 2/8] Refresh the Divergence Ledger and Clear Three Spent Backlog Claims (#641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintenance, in three parts, none of which changes a rule. ## The ledger is regenerated `reports/divergences.md` is generated by a live pass over each repo's ground-truth branch, so a committed copy is only as current as its last run, which was `3f91868` on 2026-08-06. This run moves three rows: `AGENTS.md` "Fleet Bootstrap" is now divergent at Blog and HomeAutomation-Config, and `GOVERNANCE.md` "Verification Discipline" and "Workflow YAML Conventions" widen from one repo to four. The fleet re-vendor sweep reads this file as its work list, so it wanted regenerating before the sweep rather than during it. The reason `TODO.md` gave for regenerating did not survive the run, which is the more useful finding. It said the committed copy still rendered `repo-config/configure.sh` under a re-vendor disposition that the retirement decision replaced. That copy already carried `retire`, so the warning described the decision rather than the file, and a session acting on it would have gone looking for a row that was already correct. The line now states why the file goes stale at all and records what this run moved. ## Two spent claims are cleared The "Verified Complete, Awaiting Close" entry for #578 is deleted, since #578 closed on 2026-08-08. Deleting it loses nothing the fleet still needs: the re-vendor entry under "Fleet Sweeps" already names all three `GOVERNANCE.md` sections the #578 widening touches, and it keeps the `[issue-578]` reference definition in use. The README rework cluster's `State` read `backlog`, which is not one of the four values `TODO.md` defines at the top of the file, so a reader following the selection procedure had nothing to match it against. It is now `decision`, on where `## Build Artifacts` belongs, which is the only thing in that cluster a hub pull request settles. The four conformance entries above it are named as landing on downstream visits, in the sense "Fleet Sweeps" gives that phrase. ## Verification `scripts/prose_lint.py` as CI invokes it (`. --check charset --check semicolon ...`) exits 0, `scripts/repo_gate.py` reports `eol`, `eol-coverage` and `sha-pin` clean, `editorconfig-checker` exits 0, and `markdownlint-cli2` reports 0 issues over both files. Both files are CRLF and stayed CRLF. One note for a future run, since it cost a confusing minute here: pointing `prose_lint.py` at `reports/divergences.md` **by path** reports 12 `dash` violations, because naming a file explicitly bypasses the `GENERATED_TREES` exemption that holds `reports/` out of the corpus. The generator's own phrasing is not an author's, which is what that exemption is for, and the CI invocation scans `.` and is unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- TODO.md | 11 +++-------- reports/divergences.md | 5 +++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index e15382e0..34d78bd5 100644 --- a/TODO.md +++ b/TODO.md @@ -124,7 +124,7 @@ One pull request measuring the remaining carried surface against the carry-versu The spec rework and its audit check shipped. What remains is the per-repo conformance the check now reports, and one section the fleet carries that the model does not name. -**State** `backlog`. **Touches** each repo's `README.md` on its next visit, plus [`spec/readme-structure.md`][readme-structure] and [`spec/readme-sections.json`][readme-sections] if `Build Artifacts` is adopted. **Cost** one edit per repo, driven by the finding rather than by a sweep. +**State** `decision`, on where `## Build Artifacts` belongs, which is the only thing here a hub pull request settles. The four conformance entries above it are not selectable as hub work at all: each lands on a repo's own next visit, in the sense "Fleet Sweeps" below gives that phrase, and they sit here rather than there because the finding counts are what the shipped check measures. **Touches** each repo's `README.md` on its next visit, plus [`spec/readme-structure.md`][readme-structure] and [`spec/readme-sections.json`][readme-sections] if `Build Artifacts` is adopted. **Cost** one edit per repo, driven by the finding rather than by a sweep. - **Work off the conformance backlog the `readme-structure` dimension now reports.** Measured across all 22 cataloged repos on 2026-08-08, against the shipped checks: 73 findings, 71 on sections and 2 on shields, plus the 3 retired-badge findings the entry below carries. - **Blocked by** - Nothing, and no repo is edited by the hub. Each lands on its own next visit. @@ -390,7 +390,7 @@ Work that lands on a downstream visit rather than as a hub pull request, so it i Blog is the pilot. A sweep is proven there before any fleet-wide rollout, because it is the smallest tree, `hugo` plus `source-only` with no build to break, cataloged and audited on 2026-08-05, and one of only two repos carrying `AGENTS.md` "Fleet Bootstrap" today, so a carried-section change can be observed arriving there. The other carrier is HomeAutomation-Config, which is `operational` and therefore exercises the direct-to-`develop` path rather than the pull request one, which is the second visit worth making rather than the first. -Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since the committed copy predates the retirement decision and renders `repo-config/configure.sh` under a re-vendor disposition that no longer applies to it. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists. +Regenerate [reports/divergences.md][divergences-report] before using it as the work list, since it is a live pass over each repo's ground-truth branch and the committed copy is only as current as its last run. A stale ledger is the same hazard as a stale exemption, in that it hands out a work list measured against a tree that no longer exists. The reason this line used to give, that the committed copy still rendered `repo-config/configure.sh` under a re-vendor disposition, did not survive the check: that copy already carried the `retire` disposition, so the warning was true of the decision rather than of the file. What the 2026-08-09 regeneration actually moved was three rows, adding `AGENTS.md` "Fleet Bootstrap" as divergent at Blog and HomeAutomation-Config, and widening `GOVERNANCE.md` "Verification Discipline" and "Workflow YAML Conventions" from one repo to four. - **Re-vendor the changed `verbatim` content, which is one sweep covering seven files.** Every repo holding a copy of a changed section is byte-mismatched against the hub until it takes the new one, which the audit reports as stale rather than modified. - **Hub state** - Done, verified `develop` at `3d1a0b1` on 2026-08-06 for the sections below, with the prose batch adding five more [`GOVERNANCE.md`][governance] sections, verified `develop` at `d791930` on 2026-08-07. @@ -465,12 +465,7 @@ Actions on issues that are the maintainer's to take, each carrying its evidence Each was checked against the tree and has nothing left to do anywhere. Closing is the maintainer's call, and each wants the evidence quoted in the closing comment rather than a bare close. -- **[#578][issue-578], three rules that state the common case and leave the recurring one unstated.** Complete on all three items. - - **Fixed by** - The pull request carrying this entry, since the fix and the entry ship in one squash and the closing comment cites that SHA. - - **Checked** - `develop` at `a706ddb` on 2026-08-08, where all three gaps were re-read before the widening was written. - - **Closing evidence** - [`GOVERNANCE.md`][governance] "Branching Model" now states that an issue closes when its work is verifiably complete and that the keyword automates the case where completion and promotion coincide rather than adding a condition to it, naming work complete on `develop` with no promotion imminent as the second hand-close case beside a promotion that merged without the keyword, which answers item 1. "Communicating with the User" now says the message carrying the clickable link comes **before** the prompt rather than merely alongside it, since a prompt blocks on an answer and a later message arrives after that answer is given, which answers item 2. "Operational Repositories" now states when to decline the direct-commit grant, as a shape rather than a line count, and records that it stays guidance because a `pull_request` rule on the operational ruleset would gate the direct push and withdraw the allowance, which answers item 3. - - **Detail** - The item 1 rule sits in "Branching Model" rather than the "Git and Commit Rules" the retired cluster named, which is where the re-vendor has to look for it. - - **Detail** - Sweeping item 3 by term rather than by the instance the issue named found [`WORKFLOW.md`][workflow] section 3 restating the same allowance with the same silence, so it now points at the section that owns the test rather than repeating it, which is one rule in one place and a cross-reference for the second reader. +Nothing is awaiting close today. [#578][issue-578] was the last entry here and closed on 2026-08-08, and the part of it the fleet still owes is carried by the re-vendor entry under "Fleet Sweeps", which names the three sections it touches. diff --git a/reports/divergences.md b/reports/divergences.md index 1ec747dc..e203949c 100644 --- a/reports/divergences.md +++ b/reports/divergences.md @@ -35,6 +35,7 @@ Generated by `python3 spec/fidelity_honesty.py --report` - do not hand-edit. Cur A past hub revision, not the current canonical - the audit already flags these as DRIFT. Copy the current file down. No judgment needed. +- **AGENTS.md > Fleet Bootstrap** (2): Blog, HomeAutomation-Config - **AGENTS.md > Context and Delegation Discipline** (1): Financial-Modeling - **AGENTS.md > Where the Rules Live** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Foundational Principles** (1): Financial-Modeling @@ -46,10 +47,10 @@ A past hub revision, not the current canonical - the audit already flags these a - **GOVERNANCE.md > Operational Repositories** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Pull Request Title and Commit Message Conventions** (1): Financial-Modeling - **GOVERNANCE.md > Documentation Style Conventions** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner -- **GOVERNANCE.md > Verification Discipline** (1): Financial-Modeling +- **GOVERNANCE.md > Verification Discipline** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > PR Review Etiquette** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Communicating with the User** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner -- **GOVERNANCE.md > Workflow YAML Conventions** (1): Financial-Modeling +- **GOVERNANCE.md > Workflow YAML Conventions** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner - **GOVERNANCE.md > Supported Development Platforms** (1): Financial-Modeling - **GOVERNANCE.md > Editor and Tasks** (1): Financial-Modeling - **GOVERNANCE.md > Repository Details** (4): Blog, Financial-Modeling, HomeAutomation-Config, PhotoCleaner From fffac74984184a581bb4c2b8dcbc3653ba508d65 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 10:24:15 -0700 Subject: [PATCH 3/8] Run the Diff-Scoped Doc Gates in a Hook This Repository Actually Has (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TODO.md` carried a chore to reconsider whether the pre-commit hook should run the doc gates now that they are diff-scoped. Measuring it first, as the entry asked, found the premise was false for this repository. ## There was no hook `git ls-files .husky` returned nothing. `.husky/` held only `_/husky.sh`, which is gitignored, and `core.hooksPath` sat in `.git/config`, which no clone carries. Every commit in this tree ran nothing. Both [`GOVERNANCE.md`](../blob/develop/GOVERNANCE.md) and [`scripts/README.md`](../blob/develop/scripts/README.md) described what "the `.husky/pre-commit` hook" runs. That was true of the fleet convention and not of this tree, which is the shape a reader has no way to catch. The cataloged snippet would not have closed it either: it runs `dotnet husky run`, and there is no .NET here. ## The measurement that answers the original question | Command | Time | | --- | --- | | `prose_lint.py .` whole-tree, as CI runs it | 2.22 s | | `prose_lint.py . --diff HEAD` | 0.13 s | | `prose_lint.py . --diff HEAD~1` | 0.30 s | | `repo_gate.py --check eol` | 0.04 s | The standing decision to keep doc linters out was made against the 2.2-second sweep. What reverses it is the diff scope rather than a change of preference, so both documents now say that rather than simply asserting the new arrangement. ## What is deliberately absent `repo_gate.py --check sha-pin` stays in CI. It measured 0.03 s here only because all 45 pins are under another owner and were read for shape alone. It resolves a same-owner pin against the GitHub API, and a hook that needs a network fails offline and on a slow link. The hook does not source `_/husky.sh`. That file is gitignored and arrives with an npm install this repository does not have, so sourcing it would break the hook in a fresh clone. The `.husky/` path is kept regardless, because the fleet's line-ending rules are written against it by name. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits rather than only the staged ones. That is stated in the hook and in `scripts/README.md` rather than glossed, and CI's whole-tree run is the backstop that makes it affordable. ## Line-ending pins `GOVERNANCE.md` "Line Endings" makes both pins mandatory for a repository that ships an extensionless hook, `.gitattributes` for git and `.editorconfig` for the editor, since the git pin alone leaves the editor free to write a CRLF shebang and break it. Both are added. `eol-coverage` now reads 19 LF pins over 19 shebang files, up from 18 over 18. ## Verification The hook rejected its own first draft, on 13 `comment-wrap` findings across the comments I had just written, which is the exact failure the chore named. Fixed and re-run, it exits 0, and it ran on the commit in this pull request. Full CI-equivalent set is clean: `prose_lint.py` with the CI check list exits 0, `repo_gate.py` reports `eol`, `eol-coverage` and `sha-pin` clean, `editorconfig-checker` exits 0, and `markdownlint-cli2` reports 0 issues across 44 files. The hook is committed `100755`, and is LF. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- .editorconfig | 5 +++++ .gitattributes | 2 ++ .husky/pre-commit | 37 +++++++++++++++++++++++++++++++++++++ GOVERNANCE.md | 2 +- TODO.md | 1 + scripts/README.md | 2 +- 6 files changed, 47 insertions(+), 2 deletions(-) create mode 100755 .husky/pre-commit diff --git a/.editorconfig b/.editorconfig index 08aeec34..2ef0c084 100644 --- a/.editorconfig +++ b/.editorconfig @@ -60,6 +60,11 @@ end_of_line = lf [catalog/snippets/husky/pre-commit] end_of_line = lf +# This repository's own hook, paired with the `.gitattributes` pin. +# The git pin alone leaves the editor free to write a CRLF shebang, which would break it. +[.husky/pre-commit] +end_of_line = lf + # Linux scripts [*.sh] end_of_line = lf diff --git a/.gitattributes b/.gitattributes index 1c34a355..53f037cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,6 +13,8 @@ *.sh text eol=lf # The husky pre-commit snippet is an extensionless shebang script (like a copied .husky/pre-commit). catalog/snippets/husky/pre-commit text eol=lf +# This repository's own hook, which is the extensionless case the comment above names. +.husky/pre-commit text eol=lf # Vanilla `.py` follows the CRLF default, since Python's universal newlines accept CRLF and it is commonly edited on Windows. # Pin LF only for a `.py` executed directly via its shebang, by path. diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..dce628bd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,37 @@ +#!/bin/sh +# Local pre-commit gate for this repository: the doc checks CI runs, on the commit's own scope. +# Enable it per clone with `git config core.hooksPath .husky`. +# A clone carries no hooks path, so this file does nothing until that is set. +# It deliberately does not source `_/husky.sh`. +# That file is gitignored and arrives with an npm install this repository does not have. +# 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. +# +# `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. +# The doc linters that need Docker stay in CI and in the VS Code Lint tasks. +set -e + +# Git already runs a hook from the top level, measured by committing from `scripts/` and printing `pwd`. +# This is belt and braces for an invocation that does not come from git. +# The relative paths below would otherwise resolve against whatever directory the caller was in. +cd "$(git rev-parse --show-toplevel)" + +# The prose gate is scoped to what changed against HEAD, which is the policy for prose. +# A rule is applied as a file is next edited rather than swept across the tree. +# Whole-tree costs about 2.2 seconds where the diff-scoped run costs about 0.13. +# The scope is the working tree rather than the index. +# A partially staged file is therefore judged on all of its edits, not only the staged ones. +# CI re-runs the same rules over the whole tree, which is what makes that affordable here. +python3 scripts/prose_lint.py . --diff HEAD + +# The eol check is repo-wide rather than diff-scoped, and it is here because it is already fast. +# It reads `.gitattributes` against `.editorconfig` for the whole repository and takes no file list. +# At about 0.04 seconds there is nothing to scope, so scoping it would only make it wrong. +python3 scripts/repo_gate.py --check eol diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 3d9bef33..534acd25 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -368,7 +368,7 @@ 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). 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. -- **The `.husky/pre-commit` hook** runs **language formatting only**: CSharpier + `dotnet format` (or ruff) via native tooling, no Docker and no doc linters, so it stays fast. +- **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. The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc or headless (agent) runs. diff --git a/TODO.md b/TODO.md index 34d78bd5..45e2a964 100644 --- a/TODO.md +++ b/TODO.md @@ -375,6 +375,7 @@ Small work with no research to preserve, selectable one bullet at a time. - **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 and against a hook that runs the gate from the wrong directory, which is its own false clean. - **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 beside it 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/scripts/README.md b/scripts/README.md index 9d684e26..83d13242 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,6 @@ # Repo Scripts -The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches: `markdownlint`, `cspell`, `actionlint`, and `editorconfig-checker` all report clean on prose that breaks a documented [`GOVERNANCE.md`][governance] rule. Doc linters stay out of the pre-commit hook, which runs language formatting only so it stays fast. +The fleet's checks and review tooling, run by hand, with the deterministic ones also gating CI. Each one exists because the CI linters pass on the failure it catches: `markdownlint`, `cspell`, `actionlint`, and `editorconfig-checker` all report clean on prose that breaks a documented [`GOVERNANCE.md`][governance] rule. The pre-commit hook runs two deterministic doc gates, each at the scope that fits it. `python3 scripts/prose_lint.py . --diff HEAD` is diff-scoped, at about 0.13 seconds where its whole-tree sweep costs about 2.2. `python3 scripts/repo_gate.py --check eol` is repo-wide, since it reads `.gitattributes` against `.editorconfig` and takes no file list, and at about 0.04 seconds there is nothing to scope. The earlier decision to keep doc linters out was made when a sweep was the only mode, and what reversed it is the diff scope rather than a change of preference. The gates needing Docker, and `sha-pin` which resolves a pin against the GitHub API, stay in CI. The hook reads the working tree rather than the index, so a partially staged file is judged on all of its edits, which CI's whole-tree run is the backstop for. **Hosted here and reached, never carried.** These are not declared in [`spec/files.json`][files], so the audit does not expect a downstream repo to ship them, the same footing as `spec/audit.py`. That is the fleet model rather than an omission: a script holding no per-repo content is one copy for the fleet, run from a hub checkout against the repository named on the command line, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. A repository that cannot reach the hub reports the check as not run rather than reconstructing it, since a rebuilt gate encodes its author's reading of the rule and agrees with no other repository. CI reaches the same rules through the [`prose-gate`][prose-gate-action] composite action, which a caller pins to a commit SHA. It reads the copy bundled at that pin only where the run targets `main`, and takes the rules from hub `develop` on every other target, a feature-branch push included, so a released repo's gate is reproducible while every branch below it exercises a rule change before that change reaches `main`. A caller wanting one specific hub ref passes `rules-ref` and overrides both. From 765969e4d3049c107411b344addd5760a00351c7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 11:06:47 -0700 Subject: [PATCH 4/8] Say the Hook Header the Way the Body Already Says It (#645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the one finding on the promotion pull request #643, which cannot take a fix commit of its own: its head is `develop`, so the correction reaches it by landing here first. > The header comment says the hook runs doc checks "on the commit's own scope", but the script later documents (and implements) scope as the working tree diff against HEAD (not the index / staged content). Accepted. The header named the staged content where the hook evaluates the working tree diff against `HEAD`. The body of the same file already said so, and said why, so the header was the single line disagreeing both with the implementation and with the three sentences under it. That is the worse shape of the two, since a reader who stops at the header gets a wrong answer that the file itself contradicts further down. It now says each gate runs at the scope that fits it, which is what the file documents and what #642 settled: the prose gate is diff-scoped against `HEAD`, and the line-ending check is repo-wide because it reads `.gitattributes` against `.editorconfig` and takes no file list. The hook ran on this commit and exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- .husky/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index dce628bd..cf97cf75 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,5 @@ #!/bin/sh -# Local pre-commit gate for this repository: the doc checks CI runs, on the commit's own scope. +# Local pre-commit gate for this repository: the doc checks CI runs, each at the scope that fits it. # Enable it per clone with `git config core.hooksPath .husky`. # A clone carries no hooks path, so this file does nothing until that is set. # It deliberately does not source `_/husky.sh`. From 9eb2510e423e49af5a1b7d53455e5cec6e16110c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 12:03:55 -0700 Subject: [PATCH 5/8] Take the Auto-Fixable Ruff Findings, and Declare the Python Floor They Turn On (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanically safe slice of the ruff backlog `TODO.md` recorded, plus the host floor that decides how much of that slice is safe. > **Note on this description.** It originally described the first commit, which declined `UP017` on the ground that no python3 floor was declared. The maintainer then set the floor at 3.13, which reverses that decision, and the second commit takes the rewrite instead. The body below describes the change as it now stands. The superseded reasoning is kept in the commit history rather than here, since a description that argues for the opposite of its own diff is worse than one that is merely terse. ## The ruff slice This repository declares `[tool.ruff]` in `pyproject.toml` and `spec/project-types.json` declares `python.ruff.config`, yet no workflow runs ruff and the tree has never passed it. The full backlog is 106 findings plus a formatter that would rewrite 10449 lines across all 13 substantial Python files. This takes the auto-fixable findings only: import ordering, `re.M` spelled `re.MULTILINE`, two slice-to-`removeprefix` rewrites, an explicit f-string conversion, an unused `noqa`, a pair of extraneous parentheses, and `UP017`. The 70 findings needing judgment stay open, as does the formatter. Each fix pass exposed the next, so the set was run to convergence rather than once. `UP017` rewrote the call sites and left `timezone` imported but unused in three files, `F401` removed those imports, and that left three import blocks unsorted for `I001`. A hand-picked rule set does not settle in one pass the way the full set does. ## The python3 floor, at 3.13 `spec/host-tools.json` declared `"minimum": null` for python3, on the stated ground that no floor had been measured. That was not true of the tree: `str.removeprefix` and `str.removesuffix` are called in `spec/audit.py` and `scripts/prose_lint.py` on `develop`, which is a 3.9 requirement that had been there unmeasured and undeclared, and is exactly the gap that entry's own wording warns about. The entry now declares `3.13` with the `source` block the schema requires once a floor exists. It is a **target** floor rather than a **measured** one, and the `why` says so outright: `pyproject.toml` sets ruff to `py313` and mypy to `3.13`, so the lint and type results this repo gates on describe 3.13 and describe no other interpreter, and a run below the floor is unverified rather than known broken. The two measured requirements are recorded beside it at 3.9 and 3.11, since those are what an older interpreter actually fails on, as an `AttributeError` at the call rather than at startup. That distinction did not exist before this change, and four places stated the single-kind rule that a floor exists only where a version is known to break a documented procedure. All four now carry both kinds: the note in `spec/host-tools.json`, the `minimum` description in `spec/host-tools.schema.json`, `docs/host-setup.md` "Where a Tool Comes From, and How Old It May Be", and `scripts/README.md`. Sweeping by the term rather than by the instance is what found the last two. ## The test that failed, which is the test working `scripts/test_host_gate.py` asserted the floor set as exactly `gh` and `git-restore-mtime`, and it failed on this change. That assertion exists so adding a floor is a deliberate edit in the test rather than a silent one in the data, and it did that job. It now asserts the three-name set, and a second case asserts that a target floor identifies itself in its `why`, so a reader does not go hunting for a defect report that does not exist. ## Verification All 551 tests pass, `spec/validate.py` exits 0, `scripts/host_gate.py` exits 0 reporting `python3 3.13.5 meets the 3.13 floor`, and both the audit and write-guard self-tests report `SELFTEST PASS`. Every `.py` line ending is unchanged, checked by recording all 13 files' endings before and after and diffing the lists, since ruff rewrites in place and this repository pins endings per path. `prose_lint.py` with the CI check list exits 0, `repo_gate.py` reports `eol`, `eol-coverage` and `sha-pin` clean, `editorconfig-checker` exits 0, and `markdownlint-cli2` reports 0 issues across 44 files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/host-setup.md | 2 +- host-setup/agent-safety/gh-write-guard.py | 8 +++---- host-setup/agent-safety/install.py | 2 +- scripts/README.md | 2 +- scripts/host_gate.py | 7 +++++- scripts/pr_review.py | 18 +++++++++++----- scripts/prose_lint.py | 9 +++++++- scripts/repo_gate.py | 10 ++++++--- scripts/test_host_gate.py | 26 +++++++++++++++++++---- scripts/test_pr_review.py | 15 +++++++++---- scripts/test_prose_lint.py | 14 +++++++++--- scripts/test_repo_gate.py | 10 ++++++++- spec/audit.py | 24 ++++++++++----------- spec/host-tools.json | 11 +++++++--- spec/host-tools.schema.json | 2 +- spec/validate.py | 2 +- 16 files changed, 115 insertions(+), 47 deletions(-) diff --git a/docs/host-setup.md b/docs/host-setup.md index 4c6366b9..add21460 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -30,7 +30,7 @@ A missing tool is a host gap, not a repo problem. Install it and re-run, rather ### Where a Tool Comes From, and How Old It May Be -Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor exists only where a version is known to break a documented procedure, so most entries carry none, deliberately: a floor nobody can justify becomes a host failure nobody can act on. +Presence is the weaker half of this contract. Both host defects this fleet has actually hit are **version** facts on a tool that is installed, answers `--version`, and looks healthy, so the table above cannot see either one. [`spec/host-tools.json`][host-tools] carries the floors as data and records the defect each one encodes, and [`scripts/host_gate.py`][host-gate] reads it. A floor is one of two kinds and names its own kind in the `why` it carries. A **measured** floor sits above a version known to break a documented procedure, which is what both `gh` and `git-restore-mtime` carry. A **target** floor names the version the repo's own toolchain is configured for, which is what `python3` carries at 3.13, where `pyproject.toml` sets ruff and mypy to that version, so a lower interpreter is unverified rather than known broken and the entry says exactly that. Everything else carries none, deliberately: a floor nobody can justify becomes a host failure nobody can act on. **`gh` must not come from the distribution's package on Linux.** This is the one place this document names a source, because here the source *is* the requirement rather than a convenience. The GitHub CLI maintainers state that the community-distributed `2.45.x` / `2.46.x` is **broken by deprecated GitHub APIs**, so install from the official apt repository at [cli.github.com][cli-install-link] and upgrade from there. Both `gh` limitations recorded in [`OPERATIONS.md`][operations] were observed on a host carrying a distribution `gh 2.46.0`, and both are the deprecation class that note describes. On **Windows** `winget` tracks upstream releases, and on macOS Homebrew does, so neither raises this hazard and neither needs a note of its own. diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/gh-write-guard.py index 93ac819a..351265eb 100644 --- a/host-setup/agent-safety/gh-write-guard.py +++ b/host-setup/agent-safety/gh-write-guard.py @@ -46,17 +46,17 @@ | (?:label|secret|variable|ruleset)\s+(?:create|delete|edit|set) | gist\s+(?:create|edit|delete) )\b""", - re.X, + re.VERBOSE, ) _GH_API = re.compile(r"\bgh\s+api\b") -_EXPLICIT_WRITE_METHOD = re.compile(r"(?:--method|-X)\s+(?:POST|PUT|PATCH|DELETE)\b", re.I) +_EXPLICIT_WRITE_METHOD = re.compile(r"(?:--method|-X)\s+(?:POST|PUT|PATCH|DELETE)\b", re.IGNORECASE) # A gh api call with a field flag defaults to POST even without -X, so it is a write. _API_FIELD_FLAG = re.compile(r"(?:^|\s)(?:-f|-F|--field|--raw-field|--input)\b") -_GRAPHQL = re.compile(r"\bgh\s+api\b.*\bgraphql\b", re.S) +_GRAPHQL = re.compile(r"\bgh\s+api\b.*\bgraphql\b", re.DOTALL) _MUTATION = re.compile(r"\bmutation\b") # Loose pre-filter only: matches `git` before `push` even with global options between them # (git -C push). _push_arg_lists is the accurate arbiter that confirms an executable push. -_GIT_PUSH = re.compile(r"\bgit\b.*?\bpush\b", re.S) +_GIT_PUSH = re.compile(r"\bgit\b.*?\bpush\b", re.DOTALL) # --- Bypass-of-branch-rule detectors (Rule 4) -------------------------------------------------------- # A git operation is denied when it would only succeed by bypassing an active branch rule. diff --git a/host-setup/agent-safety/install.py b/host-setup/agent-safety/install.py index 8858c02f..8594ad54 100644 --- a/host-setup/agent-safety/install.py +++ b/host-setup/agent-safety/install.py @@ -215,7 +215,7 @@ def reject(where, held, want): newline, existing = "\n", "" for marker, filename in blocks: snippet = (HERE / filename).read_text(encoding="utf-8").strip() - block_re = re.compile(rf".*?", re.S) + block_re = re.compile(rf".*?", re.DOTALL) if block_re.search(existing): existing, action = block_re.sub(lambda _: snippet, existing), "updated" else: diff --git a/scripts/README.md b/scripts/README.md index 83d13242..60956e28 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -119,7 +119,7 @@ A stale-backticked-path check was built and **rejected**: a template repo legiti The host contract in [`docs/host-setup.md`][host-setup] as a check, reading the tool floors declared in [`spec/host-tools.json`][host-tools]. It exists because presence is the weaker half of that contract: both host defects this fleet has hit are version facts on a tool that is installed, answers `--version`, and looks healthy. -**A floor is declared only where a version is known to break a documented procedure**, and each one records that defect rather than a preference. Two exist today. A distribution `gh` in the `2.45.x` / `2.46.x` range is named broken by the GitHub CLI maintainers, and both `gh` limitations in [`OPERATIONS.md`][operations] were observed on one. A `git-restore-mtime` before `2025.08` calls `git whatchanged`, which current `git` refuses, so it restores nothing, prints its ordinary statistics and **exits 0**. Everything else is presence-only, which is deliberate, since a floor nobody can justify becomes a host failure nobody can act on. +**A floor is either measured or a target, and its `why` says which**, since a host failing one has a defect to point at where a host failing the other does not. A measured floor records the defect rather than a preference, and two exist today. A distribution `gh` in the `2.45.x` / `2.46.x` range is named broken by the GitHub CLI maintainers, and both `gh` limitations in [`OPERATIONS.md`][operations] were observed on one. A `git-restore-mtime` before `2025.08` calls `git whatchanged`, which current `git` refuses, so it restores nothing, prints its ordinary statistics and **exits 0**. Everything else is presence-only, which is deliberate, since a floor nobody can justify becomes a host failure nobody can act on. The three states a tool can be in are kept apart because their remedies differ: **absent** means install it, **unreadable** means the declared pattern is wrong and the fix is in this repo rather than on the host, and **read** means the floor applies. A probe that runs and exits non-zero is not an answer, which is what separates a tool that is missing from one this file cannot parse. diff --git a/scripts/host_gate.py b/scripts/host_gate.py index 0a316c9c..7535ef79 100755 --- a/scripts/host_gate.py +++ b/scripts/host_gate.py @@ -23,7 +23,12 @@ below. 2 = the declaration itself could not be read, which is a defect here rather than on the host. """ from __future__ import annotations -import argparse, json, re, subprocess, sys + +import argparse +import json +import re +import subprocess +import sys from pathlib import Path SPEC = Path(__file__).resolve().parent.parent / 'spec' / 'host-tools.json' diff --git a/scripts/pr_review.py b/scripts/pr_review.py index aad86bb7..fdae5b9c 100644 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -62,8 +62,16 @@ GOVERNANCE.md "Repository Boundaries and Write Safety" for the rules `reply` enforces. """ from __future__ import annotations -import argparse, io, json, re, subprocess, sys, tarfile, time -from datetime import datetime, timezone + +import argparse +import io +import json +import re +import subprocess +import sys +import tarfile +import time +from datetime import UTC, datetime from pathlib import Path REVIEWER = 'copilot-pull-request-reviewer' @@ -978,7 +986,7 @@ def checks_unreadable(pr: dict) -> bool: than as this reading having failed. A silent narrowing is the failure mode this whole script is built against, and it does not get an exception for its own newest field. """ - return bool(((pr.get('commits') or {}).get('nodes') or [])) and not head_commit(pr) + return bool((pr.get('commits') or {}).get('nodes') or []) and not head_commit(pr) def checks_tally(nodes: list[dict]) -> tuple[int, int]: @@ -1056,7 +1064,7 @@ def digest(owner: str, repo: str, num: int, seen: set[str] | None = None, """ pr = gql(Q_FULL, owner, repo, num) if pr is None else pr stalled = stall_of(owner, repo, num, pr) if stalled is None else stalled - now = datetime.now(timezone.utc) if now is None else now + now = datetime.now(UTC) if now is None else now head = pr['headRefOid'] revs = reviewer_nodes(pr, 'reviews') # `revs` is every round and `on_head` is the ones that reviewed this commit. @@ -1664,7 +1672,7 @@ def main(argv: list[str] | None = None) -> int: # The stall is re-read here rather than carried out of the loop. # A request picked up since that reading would still report as picked up by nothing. stalled = stall_of(owner, repo, a.number, final) - now = datetime.now(timezone.utc) + now = datetime.now(UTC) # Parsed here and handed down, so the digest and the exit code share one read of the rollup. # Deriving the stuck shapes from that list costs no parse, which is what was doubled. checks = check_nodes(final) diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 8653a032..93ec7228 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -17,7 +17,14 @@ Exit 1 if any violation is found. Read-only, never edits. """ from __future__ import annotations -import argparse, io, re, subprocess, sys, tokenize, unicodedata + +import argparse +import io +import re +import subprocess +import sys +import tokenize +import unicodedata from pathlib import Path from typing import NamedTuple, TypedDict diff --git a/scripts/repo_gate.py b/scripts/repo_gate.py index 228f8bb8..84bca21f 100644 --- a/scripts/repo_gate.py +++ b/scripts/repo_gate.py @@ -35,13 +35,17 @@ actionlint, editorconfig-checker, spec/validate.py). """ from __future__ import annotations -import argparse, re, subprocess, sys -from pathlib import Path, PurePosixPath + +import argparse +import re +import subprocess +import sys from fnmatch import fnmatch +from pathlib import Path, PurePosixPath # GOVERNANCE.md documents exactly one floating-ref exception. SHA_EXCEPTIONS = {'dotnet/nbgv'} -USES = re.compile(r'^\s*-?\s*uses:\s*(?P[^\s#]+)', re.M) +USES = re.compile(r'^\s*-?\s*uses:\s*(?P[^\s#]+)', re.MULTILINE) PIN = re.compile(r'^[0-9a-f]{40}$') WORKFLOW = re.compile(r'workflows/.*\.ya?ml$') # What `gh` prints when GitHub answered, as opposed to when nothing was reached at all. diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index db3a698c..b82d10c1 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -7,13 +7,14 @@ neither was visible from reading the code. """ from __future__ import annotations + import json import sys import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) -import host_gate # noqa: E402 +import host_gate def tool(name, minimum=None, required=True, probes=None, pattern=r'v(\d+(?:\.\d+)*)', **extra): @@ -403,10 +404,27 @@ def test_a_floor_carries_a_source_so_the_finding_names_a_remedy(self): if t['minimum'] is not None: self.assertTrue(t.get('source'), f'{t["name"]} declares a floor and no source to install from') - def test_the_two_known_defects_are_the_declared_floors(self): - """A floor exists only where a defect is known, so this asserts the set rather than a count.""" + def test_the_declared_floors_are_the_ones_with_a_stated_reason(self): + """A floor is justified or it is not there, so this asserts the set rather than a count. + + Two kinds qualify. A measured floor sits above a version known to break a documented + procedure, and a target floor names the version the repo's toolchain is configured for. + The set is asserted so that adding a floor is a deliberate edit here rather than a silent + one in the data, which is what caught the python3 floor being added without this line. + """ floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None} - self.assertEqual(floors, {'gh', 'git-restore-mtime'}) + self.assertEqual(floors, {'gh', 'git-restore-mtime', 'python3'}) + + def test_a_target_floor_says_so_rather_than_implying_a_defect(self): + """The python3 floor is a target, so its `why` has to distinguish itself from a measured one. + + A reader who takes a target floor for a measured one goes looking for a defect report that + does not exist, which is the failure the two-kinds wording was written to prevent. + """ + python3 = next(t for t in self.data['tools'] if t['name'] == 'python3') + self.assertEqual(python3['minimum'], '3.13') + self.assertIn('target', python3['why']) + self.assertIn('unverified rather than known broken', python3['why']) if __name__ == '__main__': diff --git a/scripts/test_pr_review.py b/scripts/test_pr_review.py index 1c5217a0..eb0bb9ac 100644 --- a/scripts/test_pr_review.py +++ b/scripts/test_pr_review.py @@ -9,8 +9,15 @@ Run as `python3 scripts/test_pr_review.py`, or under `python3 -m unittest discover -s scripts`. """ from __future__ import annotations -import contextlib, io, json, re, subprocess, sys, unittest -from datetime import datetime, timedelta, timezone + +import contextlib +import io +import json +import re +import subprocess +import sys +import unittest +from datetime import UTC, datetime, timedelta from itertools import count from pathlib import Path from unittest import mock @@ -105,7 +112,7 @@ def thread(tid: str, resolved: bool = False, login: str = pr_review.REVIEWER, # A fixed clock, so a case holds a check at a known age instead of at whatever the suite runs at. -NOW = datetime(2026, 8, 6, 17, 0, 0, tzinfo=timezone.utc) +NOW = datetime(2026, 8, 6, 17, 0, 0, tzinfo=UTC) def ago(seconds: int) -> str: @@ -121,7 +128,7 @@ def ago(seconds: int) -> str: def real_ago(seconds: int) -> str: """A timestamp `seconds` before the real clock, for the `wait` path, which reads that clock.""" - return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).strftime('%Y-%m-%dT%H:%M:%SZ') + return (datetime.now(UTC) - timedelta(seconds=seconds)).strftime('%Y-%m-%dT%H:%M:%SZ') def check(name: str = 'Check pull request workflow status job', status: str = 'COMPLETED', diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index 96765401..2847103f 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -8,7 +8,15 @@ Run as `python3 scripts/test_prose_lint.py`, or under `python3 -m unittest discover -s scripts`. """ from __future__ import annotations -import contextlib, io, json, re, subprocess, sys, tempfile, unittest + +import contextlib +import io +import json +import re +import subprocess +import sys +import tempfile +import unittest from pathlib import Path from unittest import mock @@ -129,14 +137,14 @@ class TestGovernanceCoupling(unittest.TestCase): def setUp(self) -> None: self.doc = GOVERNANCE.read_text(encoding='utf-8') - section = re.search(r'^### Character Set$(.*?)^### ', self.doc, re.M | re.S) + section = re.search(r'^### Character Set$(.*?)^### ', self.doc, re.MULTILINE | re.DOTALL) if section is None: self.fail('the Character Set heading moved, so the parse is blind') self.section = section.group(1) def tier_codepoints(self, label: str) -> set[int]: """Codepoints named in one tier's bullet, read out of the rule text itself.""" - m = re.search(rf'^- \*\*Tier {label},(.*?)(?=^- \*\*)', self.section, re.M | re.S) + m = re.search(rf'^- \*\*Tier {label},(.*?)(?=^- \*\*)', self.section, re.MULTILINE | re.DOTALL) if m is None: self.fail(f'the Tier {label} bullet moved, so the parse is blind') return {int(h, 16) for h in re.findall(r'U\+([0-9A-Fa-f]{4})', m.group(1))} diff --git a/scripts/test_repo_gate.py b/scripts/test_repo_gate.py index fa36a1e8..e82cb96a 100644 --- a/scripts/test_repo_gate.py +++ b/scripts/test_repo_gate.py @@ -8,7 +8,15 @@ Run as `python3 scripts/test_repo_gate.py`, or under `python3 -m unittest discover -s scripts`. """ from __future__ import annotations -import contextlib, io, re, shutil, subprocess, sys, tempfile, unittest + +import contextlib +import io +import re +import shutil +import subprocess +import sys +import tempfile +import unittest from pathlib import Path from unittest import mock diff --git a/spec/audit.py b/spec/audit.py index de4faec5..f1015954 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -30,7 +30,7 @@ import sys import urllib.error import urllib.request -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -162,7 +162,7 @@ def driftnote_findings(entry, spec, open_count): out.append(("DRIFT", f"registry: driftNote names check '{cid}', whose type '{owner}' this repo does not declare: {quoted}")) else: out.append(("DRIFT", f"registry: driftNote names check '{cid}', which this audit does not evaluate by id (AUDIT.md section 4) - judge it by hand and delete the note once it passes: {quoted}")) - marker = next((w for w in PENDING_MARKERS if re.search(rf"\b{re.escape(w)}\b", note, re.I)), None) + marker = next((w for w in PENDING_MARKERS if re.search(rf"\b{re.escape(w)}\b", note, re.IGNORECASE)), None) if marker and not open_count: out.append(("DRIFT", f"registry: driftNote says '{marker}' but the audit is clean - verify and reconcile: {quoted}")) elif marker: @@ -310,7 +310,7 @@ def heading_texts(markdown): return {m.group(1).strip().lower() for line in markdown.splitlines() for m in (_HEADING.match(line),) if m} -_HTML_COMMENT = re.compile(r"", re.S) +_HTML_COMMENT = re.compile(r"", re.DOTALL) _MD_LINK_INLINE = re.compile(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)") # URL may hold one level of () _MD_LINK_REF = re.compile(r"\[([^\]]*)\]\[[^\]]*\]") @@ -363,10 +363,10 @@ def tagline(intro): _MD_IMAGE_REF = re.compile(r"!\[[^\]]*\]\[([^\]]+)\]") _MD_IMAGE_INLINE = re.compile(r"!\[[^\]]*\]\((\S+?)\)") -_LINK_DEF = re.compile(r"^\[([^\]]+)\]:\s*(\S+)", re.M) +_LINK_DEF = re.compile(r"^\[([^\]]+)\]:\s*(\S+)", re.MULTILINE) # A URI scheme, requiring two or more characters so a `C:` drive letter is not read as one. # Every scheme a README actually carries (mailto, ftp, ssh, git, tel, data) is longer than that. -_URI_SCHEME = re.compile(r"[a-z][a-z0-9+.\-]+:", re.I) +_URI_SCHEME = re.compile(r"[a-z][a-z0-9+.\-]+:", re.IGNORECASE) def unfenced_text(text): @@ -711,10 +711,8 @@ def table_cells(line): s = line.strip() if "|" not in s: return None - if s.startswith("|"): - s = s[1:] - if s.endswith("|"): - s = s[:-1] + s = s.removeprefix("|") + s = s.removesuffix("|") return [c.strip() for c in s.split("|")] @@ -1210,7 +1208,7 @@ def audit_repo(entry, spec, branch=None): # Language ecosystems such as nuget, uv and npm are directory-scoped and not yet cross-checked here. db = gh(f"repos/{slug}/contents/.github/dependabot.yml?ref={ground}", ok404=True) if db and db.get("content"): - declared = set(re.findall(r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', base64.b64decode(db["content"]).decode("utf-8", "replace"), re.M)) + declared = set(re.findall(r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', base64.b64decode(db["content"]).decode("utf-8", "replace"), re.MULTILINE)) implied = {} workflows = gh(f"repos/{slug}/contents/.github/workflows?ref={ground}", ok404=True) if isinstance(workflows, list) and any(e["name"].endswith((".yml", ".yaml")) for e in workflows): @@ -1475,7 +1473,7 @@ def _selftest(): got = classify_verbatim(down, canon_t, history) if got != want: ok = False - print(f" {'ok ' if got == want else 'FAIL'} want={str(want):>8} got={str(got):>8} verbatim: {label}") + print(f" {'ok ' if got == want else 'FAIL'} want={want!s:>8} got={got!s:>8} verbatim: {label}") # Action-pin neutralization, where a Dependabot uses:@ bump, meaning both the 40-hex sha and its ` # vN` comment, must not count as verbatim drift, while a changed action name must. # This is what lets a verbatim workflow region survive routine action bumps while still catching a real fork. pin_a = " - uses: actions/checkout@" + "a" * 40 + " # v7.0.0\n" @@ -1713,7 +1711,7 @@ def _selftest(): "[license-shield]: https://img.shields.io/github/license/o/r\n" ) # The four repos that suffix every heading for the ToC extension must read identically to the plain form. - omit_toc = re.sub(r"^(#{2,3} .*)$", r"\1 ", conformant, flags=re.M) + omit_toc = re.sub(r"^(#{2,3} .*)$", r"\1 ", conformant, flags=re.MULTILINE) readme_cases = [ ("a conformant README, repo-specific section included", conformant, set(), True, 0), ("a second intro paragraph is not a finding", conformant.replace("A fixture repository.\n", "A fixture repository.\n\nAnd a clarifying paragraph about it.\n"), set(), True, 0), @@ -2043,7 +2041,7 @@ def main(argv=None): # Findings are a point-in-time snapshot. # Stamp the run so anything derived from it, an onboarding issue or a report, carries its own freshness signal and a reader can tell whether it still applies. - run_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + run_utc = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") hub = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, cwd=ROOT) hub_sha = hub.stdout.strip() if hub.returncode == 0 else "unknown" diff --git a/spec/host-tools.json b/spec/host-tools.json index 960c6204..885fc4de 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -1,6 +1,6 @@ { "$schema": "./host-tools.schema.json", - "note": "The host contract in docs/host-setup.md, as data a gate can read. docs/host-setup.md states which tools a host needs and which repo procedure stops working without each one; this file adds the version floors, and each floor records the defect it encodes rather than a preference. A floor exists only where a specific version is known to break a documented procedure, so most entries carry none and are presence-only, which is deliberate: a floor nobody can justify becomes a host failure nobody can act on. An entry is required unless it declares otherwise, and an optional tool that is absent is skipped rather than failed, since it is needed by one repository rather than by the fleet. Probes are tried in order and the first that runs is the answer, which is how a host whose interpreter is not called python3 still reports a version. The pattern is matched against the probe's combined output and its first capture group is the version, read as dot-separated integers, so a two-part YYYY.MM version compares correctly against a three-part semantic one.", + "note": "The host contract in docs/host-setup.md, as data a gate can read. docs/host-setup.md states which tools a host needs and which repo procedure stops working without each one; this file adds the version floors, and each floor records the defect it encodes rather than a preference. A floor is one of two kinds and says which in its own why. A measured floor sits immediately above a version known to break a documented procedure, and is the kind most floors here are. A target floor declares the version the repo's own toolchain is configured for, where a lower interpreter is unverified rather than known broken, and it is honest about that rather than implying a defect nobody found. Everything else is presence-only, which is deliberate: a floor nobody can justify becomes a host failure nobody can act on. An entry is required unless it declares otherwise, and an optional tool that is absent is skipped rather than failed, since it is needed by one repository rather than by the fleet. Probes are tried in order and the first that runs is the answer, which is how a host whose interpreter is not called python3 still reports a version. The pattern is matched against the probe's combined output and its first capture group is the version, read as dot-separated integers, so a two-part YYYY.MM version compares correctly against a three-part semantic one.", "tools": [ { "name": "docker", @@ -49,8 +49,13 @@ "required": true, "probes": [["python3", "--version"], ["py", "-3", "--version"]], "pattern": "Python (\\d+(?:\\.\\d+)*)", - "minimum": null, - "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. No version floor is declared because none has been measured, and the name rather than the version is what differs per platform, which the second probe covers." + "minimum": "3.13", + "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. Neither tool runs in CI, which gates the standard-library script tests and the prose and repo gates and nothing else, so this floor is a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured and both sit lower: str.removeprefix and str.removesuffix need 3.9 and are called in spec/audit.py and scripts/prose_lint.py, and datetime.UTC needs 3.11 and is used in spec/audit.py, scripts/pr_review.py and their tests. Those are what an older interpreter actually fails on, as an AttributeError at the call rather than at startup. The name rather than the version is what differs per platform, which the second probe covers.", + "source": { + "linux": "Whatever the platform provides at or above the floor, since the scripts need an interpreter and no packages, so no distribution or build is pinned here.", + "macos": "Whatever the platform provides at or above the floor, on the same reasoning as Linux.", + "windows": "The python.org installer, which registers py, python and python3.13 but not python3, so the gate reaches it through the py -3 probe rather than the python3 name that resolves to the Microsoft Store alias stub." + } }, { "name": "uv", diff --git a/spec/host-tools.schema.json b/spec/host-tools.schema.json index 90520d54..3c9e4fbf 100644 --- a/spec/host-tools.schema.json +++ b/spec/host-tools.schema.json @@ -63,7 +63,7 @@ "minimum": { "type": ["string", "null"], "pattern": "^\\d+(\\.\\d+)*$", - "description": "The lowest acceptable version as dot-separated integers, or null where no floor has been measured. A floor is declared only where a version is known to break a documented procedure." + "description": "The lowest acceptable version as dot-separated integers, or null where no floor applies. A floor is either measured, sitting above a version known to break a documented procedure, or a target, naming the version the repo's toolchain is configured for. The why field says which, since the two carry different weight to a host that fails one." }, "why": { "type": "string", diff --git a/spec/validate.py b/spec/validate.py index c14096fa..c9623bc9 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -429,7 +429,7 @@ def check_selector(where, applies_to): # This is Markdown only, and only where the hub ships the file. if path.endswith(".md") and (ROOT / path).exists(): hub_text = (ROOT / path).read_text(encoding="utf-8", errors="replace") - headings = {m.group(1).strip() for m in re.finditer(r"^## (.+?)\s*$", hub_text, re.M)} + headings = {m.group(1).strip() for m in re.finditer(r"^## (.+?)\s*$", hub_text, re.MULTILINE)} for elt in sections: name = elt.get("name") if isinstance(elt, dict) else elt if isinstance(name, str) and name and name not in headings: From 1286e800031b7035c164d0f7cc954727b7e479fc Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 12:38:40 -0700 Subject: [PATCH 6/8] Put the Floors in the Contract Table, and Tie the Table to the Data (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers one of the two suppressed findings on the promotion pull request #643, which cannot take a fix of its own since its head is `develop`. > This section says `python3` carries a 3.13 target floor and `scripts/host_gate.py` will enforce it as a hard minimum. The contract table above still says "Python 3", which can mislead a host on 3.12 into thinking it meets the requirement until the gate fails. Accepted, and widened to the class. The table answers presence, and a tool below its floor answers `--version` exactly like one above it, so every column reported a host as fine while `host_gate.py` failed it. That was **already** true of `gh` before this session: a host on `2.46` reads the `gh --version` row, concludes it qualifies, and then fails the floor that has been declared since it was added. Patching only the row the finding named would have left the same trap one row up, so the fix is a `Floor` column covering both. The kind is named beside the number, since the distinction decides what a failing host can do next. A **measured** floor gives them a defect to point at, and a **target** floor does not. `git-restore-mtime` carries a floor and is deliberately absent from the table, because it is optional and no procedure here needs it, which the prose below the table already explains. ## The number is now restated in two places, so a test holds them together Copying a value out of `spec/host-tools.json` into prose creates exactly the drift this column exists to prevent: the data moves, the table does not, and a host reads the stale number and stops. A test now reads the shipped `docs/host-setup.md` and asserts every **required** tool's declared floor appears in a table row. It was written before the table was correct, to confirm it fails rather than assuming it would, and the first failure it produced was a real finding rather than a bug in the test: `git-restore-mtime declares a floor and has no row in the contract table`. That is what scoped the assertion to required tools, and the reason is recorded in the test rather than left to the next reader. Confirmed it catches the case it exists for, by rewriting the table to `3.12` and running it: ```console AssertionError: False is not true : python3 declares 3.13 and no table row states it ``` ## Verification 552 tests pass, `spec/validate.py` and `scripts/host_gate.py` exit 0, `prose_lint.py` with the CI check list exits 0, `repo_gate.py` reports all three checks clean, `editorconfig-checker` exits 0, and `markdownlint-cli2` reports 0 issues across 44 files. The hook caught a wrapped comment sentence in the new test before it landed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs/host-setup.md | 16 +++++++------ scripts/test_host_gate.py | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/host-setup.md b/docs/host-setup.md index add21460..96109f08 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -14,13 +14,15 @@ Supported hosts: This section is the **contract**: which tools a host needs and which repo procedure stops working without each one. It deliberately names no installer, because `winget`, `brew` and `apt` differ per platform while the requirement does not. Per-platform install commands are tracked separately, so this table stays true on every host. -| Tool | Needed by | Present when | -| --- | --- | --- | -| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | -| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | -| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | -| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | -| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | +| Tool | Needed by | Present when | Floor | +| --- | --- | --- | --- | +| `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | +| `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | +| Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | +| `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none | +| `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none | + +The **Floor** column exists because presence and sufficiency are different questions and the answer to the first was being read as the answer to the second. A tool below its floor still answers `--version`, so every other column reports it as fine while `scripts/host_gate.py` fails it. The kind is named beside the number, since a **measured** floor sits above a version known to break a documented procedure and gives a failing host a defect to point at, where a **target** floor names the version the repo's toolchain is configured for and does not. The next section carries the reasoning behind each one. Two consequences worth reading off the table rather than discovering later. **Python 3 needs no packages**, because every script here is standard library only, so a bare interpreter is enough. And **the linters need only `docker`**, not `node`, `dotnet` or a local `markdownlint`, since each runs as a pinned image, which is what keeps a local run and CI the same check. diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index b82d10c1..bcc1ddc3 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re import sys import unittest from pathlib import Path @@ -415,6 +416,55 @@ def test_the_declared_floors_are_the_ones_with_a_stated_reason(self): floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None} self.assertEqual(floors, {'gh', 'git-restore-mtime', 'python3'}) + def test_the_contract_table_carries_every_declared_floor(self): + """docs/host-setup.md restates the floors, so the doc goes stale the moment the data moves. + + The table's other columns answer presence, and a tool below its floor answers `--version` + like any other, so the Floor column is the only thing there that distinguishes present from + sufficient. A number that drifts out of step with the data is worse than an absent one, + since a host reads the table and stops. + """ + def norm(text): + """A prose label reduced to the identifier it names, so `Python 3` keys as `python3`.""" + return re.sub(r'[^a-z0-9.]', '', text.lower()) + + doc = (host_gate.SPEC.parent.parent / 'docs' / 'host-setup.md').read_text(encoding='utf-8') + rows, floor_col = {}, None + for ln in doc.splitlines(): + cells = [c.strip() for c in ln.strip('|').split('|')] if ln.startswith('| ') else None + # Reading stops at the end of the contract table rather than at the end of the file. + # A later table's first column is a key like any other and would overwrite a tool row. + # The document carries a second table today whose keys collide with nothing. + # A test that depends on that is a test the next table silently breaks. + if floor_col is not None and cells is None: + break + if cells is None: + continue + # Every lookup here is on one named cell, never on the row and never on a fixed index. + # Searching the row matches the probe command in `Present when` as well. + # A table stating a version there would then satisfy this with the Floor column deleted. + # That is the failure this test exists to catch rather than to reproduce. + if floor_col is None: + if 'floor' in [c.lower() for c in cells]: + floor_col = [c.lower() for c in cells].index('floor') + continue + if cells: + rows[norm(cells[0])] = cells + self.assertIsNotNone(floor_col, 'docs/host-setup.md has no contract table with a Floor column') + for t in self.data['tools']: + # An optional tool is deliberately outside the table, which lists what a host must provide. + # `git-restore-mtime` carries a floor and no row, and that is correct. + if t['minimum'] is None or not t.get('required'): + continue + key = norm(t['name']) + self.assertIn(key, rows, f'{t["name"]} declares a floor and has no row in the contract table') + cells = rows[key] + self.assertGreater(len(cells), floor_col, + f'the {t["name"]} row has no Floor cell') + self.assertIn(t['minimum'], cells[floor_col], + f'{t["name"]} declares {t["minimum"]} and its Floor cell says ' + f'{cells[floor_col]!r}') + def test_a_target_floor_says_so_rather_than_implying_a_defect(self): """The python3 floor is a target, so its `why` has to distinguish itself from a measured one. From bfef86a77000e68d9eb1aed7c99a67bbdb913ac9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 12:53:50 -0700 Subject: [PATCH 7/8] Probe the Interpreter the Way the Host Contract Declares It (#647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the suppressed finding on the promotion pull request #643, which cannot take a fix of its own since its head is `develop`. > The hook invokes `python3` directly, but docs/host-setup.md notes that on native Windows the python.org install registers `py` (and `python`) but not `python3`, so this hook will fail for Windows contributors running Git hooks via Git Bash. Accepted. This is the repository contradicting itself: `docs/host-setup.md` states the hazard in its own paragraph, and `spec/host-tools.json` already encodes the answer as its probe order, `[["python3", "--version"], ["py", "-3", "--version"]]`. The hook restated a name the contract says is wrong on one supported platform. It now follows the same order rather than naming an interpreter. ## Checked on a PATH that has `py` and no `python3` Rather than reasoning about Windows from Linux, the shape was reproduced: a PATH carrying `git`, `sh` and a stand-in for the Windows launcher, and no `python3` at all. ```console $ PATH=$D command -v python3 || echo NO NO $ PATH="$D" sh .husky/pre-commit [ok ] eol 0 issue(s) exit=0 ``` The previous version on that same PATH: ```console $ PATH="$D" sh "$D/old-pre-commit" old-pre-commit: 32: python3: not found exit=127 ``` So this fixes a real break rather than hardening against a hypothetical one. Worth being precise about the failure it was: exit 127 is non-zero, so the old hook **blocked** the commit rather than passing it falsely. The defect is that it was unusable on such a host, not that it was silently wrong. ## With neither interpreter present ```console pre-commit: no python3 and no py -3 on PATH, so the doc gates did not run. pre-commit: see docs/host-setup.md 'What a Host Must Provide'. exit=1 ``` It names what did not run and points at the contract, and exits non-zero. A hook that cannot run its gates must not report a clean commit, which is the same rule `prose_lint.py` follows when it announces a dropped rule rather than going quiet. ## Verification 552 tests pass, `prose_lint.py` with the CI check list exits 0, and `repo_gate.py` reports `eol`, `eol-coverage` and `sha-pin` clean. The hook ran on this commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- .husky/pre-commit | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index cf97cf75..53028209 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -23,15 +23,30 @@ set -e # The relative paths below would otherwise resolve against whatever directory the caller was in. cd "$(git rev-parse --show-toplevel)" +# 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. +# The stub is on PATH and fails when run, so a presence test selects it and the hook then breaks. +# Running the probe is the whole point: it is what tells a working interpreter from a name. +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 + echo "pre-commit: see docs/host-setup.md 'What a Host Must Provide'." >&2 + exit 1 +fi + # The prose gate is scoped to what changed against HEAD, which is the policy for prose. # A rule is applied as a file is next edited rather than swept across the tree. # Whole-tree costs about 2.2 seconds where the diff-scoped run costs about 0.13. # The scope is the working tree rather than the index. # A partially staged file is therefore judged on all of its edits, not only the staged ones. # CI re-runs the same rules over the whole tree, which is what makes that affordable here. -python3 scripts/prose_lint.py . --diff HEAD +run_py scripts/prose_lint.py . --diff HEAD # The eol check is repo-wide rather than diff-scoped, and it is here because it is already fast. # It reads `.gitattributes` against `.editorconfig` for the whole repository and takes no file list. # At about 0.04 seconds there is nothing to scope, so scoping it would only make it wrong. -python3 scripts/repo_gate.py --check eol +run_py scripts/repo_gate.py --check eol From 25e01e345a240c54753a8ca1c1f936551d57322b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 9 Aug 2026 13:11:44 -0700 Subject: [PATCH 8/8] Say How Each Measured Requirement Actually Fails (#648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the suppressed finding on the promotion pull request #643, which cannot take a fix of its own since its head is `develop`. > The python3 tool's `why` text says older interpreters fail with an AttributeError "at the call rather than at startup", but spec/audit.py and scripts/pr_review.py now import `UTC` from `datetime`, which raises ImportError at import time on Python < 3.11. Accepted. The sentence collapsed two requirements into one failure mode and was wrong for one of them. `str.removeprefix` is a method call and fails as the text described. `datetime.UTC` arrives through a module-level import: ```console $ grep -n "from datetime import" spec/audit.py scripts/pr_review.py scripts/test_pr_review.py spec/audit.py:33:from datetime import UTC, datetime scripts/pr_review.py:74:from datetime import UTC, datetime scripts/test_pr_review.py:20:from datetime import UTC, datetime, timedelta ``` So on an older interpreter that module raises before any of it runs. Constructed both failures rather than describing them, since the whole point of the sentence is to name the symptom a reader is looking at: ```console import-time : ImportError -> cannot import name '...' from 'datetime' call-time : AttributeError -> 'str' object has no attribute 'removeprefix' ``` The entry now names each requirement with its own mode, and says the import failure is the one a host meets first, because the script does not start at all. This matters more than a wording nit because of where the text is read. `why` is what `scripts/host_gate.py` prints to a host that just failed the floor. Telling that reader to expect an `AttributeError` partway through a run, when what they will actually see is an `ImportError` before anything runs, sends them looking for a different problem. ## Verification `spec/validate.py` exits 0, `scripts/host_gate.py` exits 0 reporting `python3 3.13.5 meets the 3.13 floor`, 552 tests pass, and `prose_lint.py` with the CI check list exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- spec/host-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/host-tools.json b/spec/host-tools.json index 885fc4de..874b00b3 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -50,7 +50,7 @@ "probes": [["python3", "--version"], ["py", "-3", "--version"]], "pattern": "Python (\\d+(?:\\.\\d+)*)", "minimum": "3.13", - "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. Neither tool runs in CI, which gates the standard-library script tests and the prose and repo gates and nothing else, so this floor is a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured and both sit lower: str.removeprefix and str.removesuffix need 3.9 and are called in spec/audit.py and scripts/prose_lint.py, and datetime.UTC needs 3.11 and is used in spec/audit.py, scripts/pr_review.py and their tests. Those are what an older interpreter actually fails on, as an AttributeError at the call rather than at startup. The name rather than the version is what differs per platform, which the second probe covers.", + "why": "Every script here is standard library only, so a bare interpreter is enough and no package floor exists. The floor is the toolchain target rather than a measured breakage one version below it, which is the one entry here that reads that way and says so rather than implying a defect nobody found. pyproject.toml sets ruff target-version to py313 and mypy python_version to 3.13, so what those tools report describes 3.13 and describes no other interpreter, and a run below the floor is unverified rather than known broken. Neither tool runs in CI. What CI does run is markdownlint, cspell, actionlint and editorconfig-checker, the registry and spec validation, the script self-tests, and the repo and prose gates, and no Python linter or type checker among them. So this floor is a configuration choice rather than an enforced result, and a host failing it has no CI failure to point at. Two hard requirements are measured, both sit lower, and they fail differently. str.removeprefix and str.removesuffix need 3.9, and each is called where the tree actually calls it: removeprefix in scripts/prose_lint.py and spec/audit.py, removesuffix in spec/audit.py alone. An older interpreter starts, runs, and raises AttributeError when it reaches one. datetime.UTC needs 3.11 and arrives through a module-level from datetime import UTC in spec/audit.py, scripts/pr_review.py and its tests, so an older interpreter raises ImportError before any of those modules run at all. Which mode a host sees is decided by the script it runs rather than by the interpreter alone: spec/audit.py carries both and fails at import, scripts/pr_review.py carries only the import and fails the same way, and scripts/prose_lint.py carries only the call and therefore starts, runs, and fails partway through. The name rather than the version is what differs per platform, which the second probe covers.", "source": { "linux": "Whatever the platform provides at or above the floor, since the scripts need an interpreter and no packages, so no distribution or build is pinned here.", "macos": "Whatever the platform provides at or above the floor, on the same reasoning as Linux.",