From 66b23826dd4f838f0835be3902f26c8a35c3b5b8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 18 Oct 2026 20:58:59 -0700 Subject: [PATCH 1/3] Rename the Carried hub-fetch-run.py Snippet to hub_fetch_run.py (#3219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the carried hook helper `catalog/snippets/hub-fetch-run.py` to `hub_fetch_run.py`, applying the snake_case Python file-name standard from #3140. ## Changes - Renames the snippet and updates every reference to it: the Husky.Net and pre-commit hook snippets, both VS Code task snippets, the catalog and snippet READMEs, `GOVERNANCE.md`, `STANDUP.md`, `AUDIT.md`, `scripts/README.md`, `docs/pre-commit-hooks-rollout.md`, `spec/project-types.json`, and the tests. - Keeps accepting the former name in the python-directories action's `CARRIED_HELPERS`, the audit's `CARRIED_PYTHON_HELPERS`, and the `parity.hooks` statements. A repository that copied the helper before this change still holds `hub-fetch-run.py`, and without this the action would warn that its helper is uncovered Python and the audit would classify the repository as Python. A new test asserts the two sets match. - Keeps the provenance label the helper writes, `(hub-fetch-run main)` and `(hub-fetch-run, commit unresolved)`, so carriers using either file name produce the same scope line. ## Does a resync leave both files? Without a new instruction, it would. `spec/files.json` has no entry for the helper, and `STANDUP.md` makes the hook files repository-owned and copied by hand, so no resync step moves or deletes the helper. A carrier that copied the new snippet would end up with both names. `RESYNC.md` step 6 now says to rename the helper in place and update its calls in the same commit. #3141's file-naming check will report any carrier that still holds the hyphenated name, and the exemption sets above cover only Python-type discovery, so they do not hide it from that check. ## Verification - `python3 -m unittest discover -s tests`: 2973 tests OK. `spec/audit.py --selftest` passes, `spec/validate.py` passes, `scripts/build_dist.py --check` reports the generated distributions current, ruff, mypy, markdownlint, and the eol checks are clean. - Mutation checks: removing the former name from the audit set fails the new selftest case, and removing it from the action's set fails both the uncovered test and the parity test. - `local-strict-review` ran three passes. The first two found a stale `parity.hooks` statement, the provenance label, a duplicated set with no test, and two long sentences, all fixed in the later commits. The third pass found nothing. The opt-in `--check sentence-length` still flags 23 long sentences on lines this change touched, but each one only had a path swapped for one of the same word count, so they were already there. Closes on promotion: #3143 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Maintenance** * Standardized the hook helper’s filename to use underscores across setup instructions, editor tasks, and hook configurations. * Updated repository checks to recognize both the current and former filename, preventing either from being reported as an uncovered Python file. --------- Co-authored-by: Claude Opus 5.5 --- .../python-directories/python_directories.py | 3 ++- AUDIT.md | 4 ++-- GOVERNANCE.md | 6 +++--- RESYNC.md | 2 +- STANDUP.md | 2 +- catalog/README.md | 2 +- .../snippets/configs/vscode-tasks-python.json | 8 ++++---- catalog/snippets/configs/vscode-tasks.json | 8 ++++---- .../{hub-fetch-run.py => hub_fetch_run.py} | 18 +++++++++--------- catalog/snippets/husky/README.md | 4 ++-- catalog/snippets/husky/pre-commit | 6 +++--- .../pre-commit/.pre-commit-config.yaml | 8 ++++---- catalog/snippets/pre-commit/README.md | 2 +- docs/pre-commit-hooks-rollout.md | 4 ++-- scripts/README.md | 2 +- spec/audit.py | 13 ++++++++++--- spec/project-types.json | 6 +++--- tests/test_cli_usage.py | 4 ++-- tests/test_hub_fetch_run.py | 4 ++-- tests/test_python_directories.py | 14 +++++++++++++- 20 files changed, 70 insertions(+), 50 deletions(-) rename catalog/snippets/{hub-fetch-run.py => hub_fetch_run.py} (90%) diff --git a/.github/actions/python-directories/python_directories.py b/.github/actions/python-directories/python_directories.py index 279b5e7f5..d1104119b 100755 --- a/.github/actions/python-directories/python_directories.py +++ b/.github/actions/python-directories/python_directories.py @@ -36,7 +36,8 @@ UNCOVERED_SHOWN = 10 # The catalog's hook snippets carry this helper into repos of every language, so it is fleet tooling rather than the repo's own Python. -CARRIED_HELPERS = {"hub-fetch-run.py"} +# A repo that copied it before the rename keeps the hyphenated name until it is next resynced. +CARRIED_HELPERS = {"hub_fetch_run.py", "hub-fetch-run.py"} def escape_command(value: str) -> str: diff --git a/AUDIT.md b/AUDIT.md index 673dac1ae..f256478d3 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -54,7 +54,7 @@ Look up the repo in [`registry/repos.json`][repos]. An entry with status `archiv Otherwise read its `types[]`. If the entry is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: - `*.csproj` / `*.slnx` -> `csharp`, a `dotnet nuget push` workflow -> `nuget`, a `System.CommandLine` console -> `console`. -- a tracked `.py` file other than the carried `hub-fetch-run.py` hook helper -> `python`, a `pypa/gh-action-pypi-publish` workflow -> `pypi`. +- a tracked `.py` file other than the carried hook helper `hub_fetch_run.py` or its former name `hub-fetch-run.py` -> `python`, a `pypa/gh-action-pypi-publish` workflow -> `pypi`. - `Dockerfile` + a docker build/push workflow -> `docker`, an `upstream-version.json` tracker -> `upstream-wrapper`. - `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`, a codegen workflow -> `codegen`, no `build-*` task -> `source-only`, governance-only -> `docs`. - `hugo.yaml` / `hugo.toml` / `config/_default/hugo.yaml` -> `hugo`. A repo may carry it alongside `source-only`, since a site deploy leaf is not a `build-*` task and both declarations stay true. @@ -88,7 +88,7 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the - **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]). - **runtime-secrets** - a repo-scoped runtime-secrets directory, when present, is named `.secrets/` (dotted, not a bare `secrets/`), a single opaque credential file carries no extension, and every real secret file has a tracked `.example` beside it cataloged in `.secrets/README.md`. N/A for a repo with no such directory. See [GOVERNANCE.md "Repo-Scoped Secrets"][governance-repo-scoped-secrets]. -- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the CLI, CI, and the editor extension where the workspace recommends one, and CI runs each. That count is of the root config, so a nested `.markdownlint-cli2.jsonc` is not a second one. A local hook exists and runs at minimum the diff-scoped prose gate and the eol check via `hub-fetch-run.py`, or the hub's own local script copies for the hub repo itself (`parity.hooks`, intent). A repo with none wired is a defect, and one mid-convergence on the language-formatting half stays operational. +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the CLI, CI, and the editor extension where the workspace recommends one, and CI runs each. That count is of the root config, so a nested `.markdownlint-cli2.jsonc` is not a second one. A local hook exists and runs at minimum the diff-scoped prose gate and the eol check via `hub_fetch_run.py`, or the hub's own local script copies for the hub repo itself (`parity.hooks`, intent). The helper's former name `hub-fetch-run.py` counts too. 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) apart from the letters of a recorded name, 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/GOVERNANCE.md b/GOVERNANCE.md index e71c9501f..ce61b2c98 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -287,9 +287,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, which Dependabot bumps. It runs **editorconfig-checker** via Docker `:latest`, since its action only installs the CLI and the Docker one-liner is what actually runs the check. **shellcheck** and **shfmt** run the same way for a repo that carries a shell script, and **PSScriptAnalyzer** does for a repo that carries `.ps1` files. None of shellcheck, shfmt, and PSScriptAnalyzer has an action. For a repo that carries an action under `.github/actions/`, the repo gate's `composite-actions` check runs **shellcheck** in Docker over each bash or sh `run:` body. It also runs **check-jsonschema** through `uvx`, because actionlint reads workflows only. The `composite-actions` check runs shellcheck at warning severity without SC2154, weaker than for a `.sh` file. 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. Catalog snippets carry the canonical shape, each with a copy of `catalog/snippets/hub-fetch-run.py` alongside it. `catalog/snippets/husky/` is Husky.Net, for a repo that already keeps a .NET tool manifest declaring it, since `dotnet tool restore` then `dotnet husky install` is what generates the `.husky/_/husky.sh` its hook sources, and the snippet ships no manifest of its own. `catalog/snippets/pre-commit/` is the `pre-commit` framework, for a repo without one and for any repo that prefers it. No snippet is scoped to a language by what it runs. Each carries the repo's own language checks plus the shared doc gates. A repo drops the block for a language it does not have. A Docker, config, or docs repo therefore wires the doc gates alone, which is a mechanism rather than the absence of one. Which snippet a repo starts from is bounded by the toolchain it already keeps, never by which languages its checks cover. A snippet is a starting point rather than the only shape: a repo may wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing, which is what this repository does. That path matters, since `parity.hooks` reads the tracked file at `.husky/pre-commit` or `.pre-commit-config.yaml` rather than a per-clone hooks path it cannot observe. +- **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. Catalog snippets carry the canonical shape, each with a copy of `catalog/snippets/hub_fetch_run.py` alongside it. `catalog/snippets/husky/` is Husky.Net, for a repo that already keeps a .NET tool manifest declaring it, since `dotnet tool restore` then `dotnet husky install` is what generates the `.husky/_/husky.sh` its hook sources, and the snippet ships no manifest of its own. `catalog/snippets/pre-commit/` is the `pre-commit` framework, for a repo without one and for any repo that prefers it. No snippet is scoped to a language by what it runs. Each carries the repo's own language checks plus the shared doc gates. A repo drops the block for a language it does not have. A Docker, config, or docs repo therefore wires the doc gates alone, which is a mechanism rather than the absence of one. Which snippet a repo starts from is bounded by the toolchain it already keeps, never by which languages its checks cover. A snippet is a starting point rather than the only shape: a repo may wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing, which is what this repository does. That path matters, since `parity.hooks` reads the tracked file at `.husky/pre-commit` or `.pre-commit-config.yaml` rather than a per-clone hooks path it cannot observe. - **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 under a plain git hook, so a partially staged file is judged on all of its edits. Under the `pre-commit` framework it is the staged state instead, since that runner sets unstaged changes aside before running a hook, which is measured rather than assumed. The pull request gate re-checks every line the branch changes against its base, which is the scope a review is judged on. A whole-repo check belongs in the hook 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. The prose gate adds one `api.github.com` commit lookup to its fetch, and those are the only network calls the doc gates make. An allow-list permitting only `raw.githubusercontent.com` still runs the gate, with the commit reported as unresolved. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, 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 tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. 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. The prose gate adds one `api.github.com` commit lookup to its fetch, and those are the only network calls the doc gates make. An allow-list permitting only `raw.githubusercontent.com` still runs the gate, with the commit reported as unresolved. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, 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 tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. CI remains the authoritative run either way. - **The hub carries a second hook, `.husky/pre-push`, gating the local-review rule rather than lint.** It runs `scripts/local_review.py check`, refusing a branch push whose diff no recorded review pass covers, per "Verification Discipline" above. It fires only for a branch update, so a tag push and a branch delete pass through, and a branch holding no net content against its target has nothing for a review to cover. It refuses rather than guesses in every state it cannot speak for, since the engine reads the checkout it runs in while a push delivers a commit, and those describe the same content only sometimes. The order that keeps them describing the same content is to commit the change, then review the diff, record its receipt, and push, the receipt being untracked and so recorded after the last commit. A change that also carries the canonical ledger records those passes ahead of that commit instead, the ledger being tracked content the commit has to hold. It reads `develop` and accepts no override from the environment, since an environment variable is set inline on the very command being gated, by whoever is being gated, which is the same reason an authorization is never read from a channel the agent itself can set. The cost is that it measures a branch based on anything but `develop` against `develop` anyway, so its refusal there is not a verdict about that branch. Each refusal names its own case, and the `local-strict-review` Skill carries the fleet's one enumeration of them with what clears each, deliberately in one place rather than restated here. It folds the engine's three-valued exit honestly: a check that could not run blocks too, and says so in different words than a check that ran and found no pass, because a gate that waves a push through when it could not run is a gate that stopped gating. It is a backstop rather than a seal, and it is bypassable by construction: `--no-verify` is the documented route out of a pickle and is not the only one, since a git hook cannot police its own invocation. A Claude Code session meets a narrower surface, that flag being denied unconditionally by the agent-safety hook's explicit-bypass rule, and that denial is Claude Code's alone, since Codex and opencode carry no such hook yet. So the committed hook raises the cost of skipping the rule for one agent and lowers it for none, and the prose layer above stays the agent-agnostic one that actually binds. It is hub-only for now. `local_review.py` is hub-hosted per "Hub-Hosted Tooling", and a downstream repo reaches it as a hub checkout's copy run with its own worktree as the working directory, so a catalog snippet carrying this hook fleet-wide is a later step rather than part of this one. `canonical_review.py` is not run here at all, and nothing else gates a push or a pull request over a canonical unit either, that read being swept on a schedule instead, per "Verification Discipline" above. The hub's own `.github/actions/validate` composite action renders its coverage burn-down into every run's job summary and fails no pull request over what that shows, while still failing where the engine could not read the manifest or the ledger at all, that being a boundary rather than a verdict. - **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 hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. @@ -319,7 +319,7 @@ In a configured editor the `DavidAnson.vscode-markdownlint` extension is enough When pulling a public image fails on a Docker-Desktop/WSL credential-helper error (`docker-credential-desktop.exe: exec format error`), retry with an empty Docker config: `DOCKER_CONFIG=$(mktemp -d) docker run ...` after writing `{}` to `$DOCKER_CONFIG/config.json`. -**Reproducing a prose-gate finding a downstream repository reports.** The gate a repository runs is the hub copy its own workflow pins, never the copy a hub checkout holds, so a rule that changed after that pin is a rule no local run has. Every verdict names its own copy on the scope line, which is where to look first: `gate /@` is a pinned remote action, `gate local ` is a checkout that tracks the script at that commit, `gate local -dirty` is the same with the file edited, and `gate unknown` is a copy no commit describes. The `hub-fetch-run.py` route is its own shape, `gate ptr727/ProjectTemplate@ (hub-fetch-run main)`, or `@main (hub-fetch-run, commit unresolved)` when the commit could not be read, so it never reads as a CI pin. A verdict carrying no `gate` clause at all predates this rule, which dates the copy just as usefully. A `local` value is meaningful only inside the repository that produced it, since it names a commit and not the repository holding it. A hand-set value is whatever its caller typed, so say in it that the run is a reproduction, or the verdict is indistinguishable from the CI one it is being compared against. Take the reporting repository's pin from the `@` in its workflow's `uses:` line, which is the commit alone rather than the whole reference that line spells, then run that copy against the same worktree and the same diff base CI used. Name the pin yourself, because an extracted copy sits under no checkout and would otherwise report `unknown`: +**Reproducing a prose-gate finding a downstream repository reports.** The gate a repository runs is the hub copy its own workflow pins, never the copy a hub checkout holds, so a rule that changed after that pin is a rule no local run has. Every verdict names its own copy on the scope line, which is where to look first: `gate /@` is a pinned remote action, `gate local ` is a checkout that tracks the script at that commit, `gate local -dirty` is the same with the file edited, and `gate unknown` is a copy no commit describes. The `hub_fetch_run.py` route is its own shape, `gate ptr727/ProjectTemplate@ (hub-fetch-run main)`, or `@main (hub-fetch-run, commit unresolved)` when the commit could not be read, so it never reads as a CI pin. A verdict carrying no `gate` clause at all predates this rule, which dates the copy just as usefully. A `local` value is meaningful only inside the repository that produced it, since it names a commit and not the repository holding it. A hand-set value is whatever its caller typed, so say in it that the run is a reproduction, or the verdict is indistinguishable from the CI one it is being compared against. Take the reporting repository's pin from the `@` in its workflow's `uses:` line, which is the commit alone rather than the whole reference that line spells, then run that copy against the same worktree and the same diff base CI used. Name the pin yourself, because an extracted copy sits under no checkout and would otherwise report `unknown`: ```sh scratch="$(mktemp -d)" diff --git a/RESYNC.md b/RESYNC.md index 5645bf671..09a10c4b5 100644 --- a/RESYNC.md +++ b/RESYNC.md @@ -81,7 +81,7 @@ The order is load-bearing. Each step below either changes the rules the later st **A skill path lands with the instruction file that names it.** Where this re-vendor adds, removes, or renames a `.github/skills` directory that `.github/copilot-instructions.md` names, edit that path in the instruction file in the same change, ahead of step 6. Copilot reads both from the pull request head, so a path naming a pruned directory leaves it reviewing without its skill, and it then posts no coverage marker. 4. **Interface workflows.** Honor the named contract (required jobs, the ruleset-bound check name, the artifact-name handoff) rather than copying bytes. The body is the repository's own. 5. **Settings, rulesets, and secrets.** From a hub checkout at `main`, run `repo-config/configure.sh check / release|operational`, then `apply` for what it reports. Pass the model explicitly. Delete each target-repository path under `repo-config/` that the audit classified `retire`. Inspect every untriaged path collision before deciding its disposition. -6. **Intent files last, and by hand.** See section 5, which states why these carry no mechanical signal at all. A repository's `.vscode/tasks.json` is a presence unit, so the audit only asserts that it exists. Bring its task groups, the lint-task images included, in line with the catalog snippet for each of its types by hand. +6. **Intent files last, and by hand.** See section 5, which states why these carry no mechanical signal at all. A repository's `.vscode/tasks.json` is a presence unit, so the audit only asserts that it exists. Bring its task groups, the lint-task images included, in line with the catalog snippet for each of its types by hand. A hook helper still named `hub-fetch-run.py` is renamed to `hub_fetch_run.py` in place. Every call to it in the hook config and `.vscode/tasks.json` changes in the same commit. Nothing mechanical carries the helper, so copying the new snippet beside it would leave both. **Reconcile the registry entry in the same pass.** `status`, `types`, `releaseTrigger`, `workflowModel` and `driftNotes` record reality rather than intent, and a `driftNote` describing work that is now finished is deleted rather than left standing. A note asserting outstanding work in prose ("pending", "not yet", "behind") contradicts a clean audit outright. diff --git a/STANDUP.md b/STANDUP.md index 5b0608a07..d69d5b98f 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -184,7 +184,7 @@ 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 with `catalog/snippets/hub-fetch-run.py` alongside it, then enable it, before the section 5 audit run. Which shape applies, what the gate must cover, and the per-clone enablement steps are all in [`GOVERNANCE.md`][governance] "Running the Linters Locally (Known-Working Invocations)", not restated here. A freshly stood-up repo with nothing wired starts pre-failed on `parity.hooks`. +**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 with `catalog/snippets/hub_fetch_run.py` alongside it, then enable it, before the section 5 audit run. Which shape applies, what the gate must cover, and the per-clone enablement steps are all in [`GOVERNANCE.md`][governance] "Running the Linters Locally (Known-Working Invocations)", not restated here. A freshly stood-up repo with nothing wired starts pre-failed on `parity.hooks`. ## 3. Stand Up the Workflows diff --git a/catalog/README.md b/catalog/README.md index 534b58fd8..f763c6c30 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -7,4 +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 a repo that already keeps a .NET tool manifest declaring it and the `pre-commit` framework for a repo without one, both carrying any language's checks plus the same shared doc gates (prose/comment-style, line endings) via `snippets/hub-fetch-run.py`. A repo may instead wire an equivalent hook of its own at `.husky/pre-commit`, sourcing nothing and enabled with `core.hooksPath`, which is what this repository's own `.husky/` does. See each directory's own README.md. +- `snippets/husky/` and `snippets/pre-commit/`: the two local commit-hook shapes, Husky.Net for a repo that already keeps a .NET tool manifest declaring it and the `pre-commit` framework for a repo without one, both carrying any language's checks plus the same shared doc gates (prose/comment-style, line endings) via `snippets/hub_fetch_run.py`. A repo may instead wire an equivalent hook of its own at `.husky/pre-commit`, sourcing nothing and enabled with `core.hooksPath`, which is what this repository's own `.husky/` does. 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 b9f0f3768..1065d4adb 100644 --- a/catalog/snippets/configs/vscode-tasks-python.json +++ b/catalog/snippets/configs/vscode-tasks-python.json @@ -136,8 +136,8 @@ // 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. - // Lint: Prose and Lint: EOL assume the pre-commit-framework layout (hub-fetch-run.py at the workspace root). - // A repo using the Husky.Net snippet instead points these two tasks at .husky/hub-fetch-run.py. + // Lint: Prose and Lint: EOL assume the pre-commit-framework layout (hub_fetch_run.py at the workspace root). + // A repo using the Husky.Net snippet instead points these two tasks at .husky/hub_fetch_run.py. { "label": "Lint: EditorConfig", "type": "process", @@ -186,7 +186,7 @@ "label": "Lint: Prose", "type": "process", "command": "uv", - "args": [ "run", "--no-project", "python", "${workspaceFolder}/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], + "args": [ "run", "--no-project", "python", "${workspaceFolder}/hub_fetch_run.py", ".github/actions/prose-gate/prose_lint.py", "." ], "problemMatcher": [], "presentation": { "showReuseMessage": false, @@ -197,7 +197,7 @@ "label": "Lint: EOL", "type": "process", "command": "uv", - "args": [ "run", "--no-project", "python", "${workspaceFolder}/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], + "args": [ "run", "--no-project", "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 aea675b19..0c7e5b4db 100644 --- a/catalog/snippets/configs/vscode-tasks.json +++ b/catalog/snippets/configs/vscode-tasks.json @@ -174,10 +174,10 @@ "label": "Lint: Prose", "type": "process", "command": "python3", - "args": [ "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/prose-gate/prose_lint.py", "." ], + "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", "." ] + "args": [ "-3", "${workspaceFolder}/.husky/hub_fetch_run.py", ".github/actions/prose-gate/prose_lint.py", "." ] }, "problemMatcher": [], "presentation": { @@ -189,10 +189,10 @@ "label": "Lint: EOL", "type": "process", "command": "python3", - "args": [ "${workspaceFolder}/.husky/hub-fetch-run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ], + "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" ] + "args": [ "-3", "${workspaceFolder}/.husky/hub_fetch_run.py", ".github/actions/repo-gate/repo_gate.py", "--check", "eol" ] }, "problemMatcher": [], "presentation": { diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub_fetch_run.py similarity index 90% rename from catalog/snippets/hub-fetch-run.py rename to catalog/snippets/hub_fetch_run.py index cc68b2e8a..e18a3ff7f 100644 --- a/catalog/snippets/hub-fetch-run.py +++ b/catalog/snippets/hub_fetch_run.py @@ -3,8 +3,8 @@ 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 +Usage: hub_fetch_run.py [script-args...] +Example: hub_fetch_run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD """ import os @@ -50,7 +50,7 @@ def resolve_main_commit() -> str | None: return sha reason = "unexpected response body" print( - f"hub-fetch-run: could not resolve the main commit ({reason}), using main.", + f"hub_fetch_run: could not resolve the main commit ({reason}), using main.", file=sys.stderr, ) return None @@ -73,14 +73,14 @@ def head_is_unborn() -> bool: timeout=PROBE_TIMEOUT, ) except (OSError, subprocess.TimeoutExpired) as exc: - print(f"hub-fetch-run: could not run git to probe HEAD: {exc}", file=sys.stderr) + 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"hub_fetch_run: git rev-parse --verify -q HEAD failed unexpectedly " f"(exit {probe.returncode}): {probe.stderr.decode(errors='replace').strip()}", file=sys.stderr, ) @@ -102,7 +102,7 @@ def resolve_unborn_head(argv: list[str]) -> list[str]: return out -USAGE = "usage: hub-fetch-run.py [script-args...]" +USAGE = "usage: hub_fetch_run.py [script-args...]" def main(argv: list[str]) -> int: @@ -111,7 +111,7 @@ def main(argv: list[str]) -> int: return 0 if not argv or argv[0].startswith("-"): problem = f"unknown option {argv[0]!r}, " if argv else "" - print(f"hub-fetch-run: {problem}{USAGE}", file=sys.stderr) + print(f"hub_fetch_run: {problem}{USAGE}", file=sys.stderr) return 2 hub_path, script_args = argv[0], resolve_unborn_head(argv[1:]) is_prose_gate = hub_path == PROSE_GATE_PATH @@ -122,8 +122,8 @@ def main(argv: list[str]) -> int: 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) - print("hub-fetch-run: the gate did not run.", file=sys.stderr) + 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) diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index ec3b92933..9c8db8151 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -5,7 +5,7 @@ runs **language formatting/lint and the fleet's shared doc gates**: CSharpier an `dotnet format` style via `dotnet husky run` for .NET, plus `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 +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,7 +27,7 @@ runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tas 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 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 +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 diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index 34a6ae062..e099a74c6 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -4,13 +4,13 @@ set -eu # 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. +# 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. +# 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" +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. diff --git a/catalog/snippets/pre-commit/.pre-commit-config.yaml b/catalog/snippets/pre-commit/.pre-commit-config.yaml index 727d1d6a3..8e06af86e 100644 --- a/catalog/snippets/pre-commit/.pre-commit-config.yaml +++ b/catalog/snippets/pre-commit/.pre-commit-config.yaml @@ -1,7 +1,7 @@ # Reference pre-commit (pre-commit.com) config for a fleet repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers this runner. # Husky.Net is a dotnet tool, so the discriminator is the toolchain rather than the repo's languages. # Drop the ruff and type-check hooks in a repo with no Python, keeping the two doc gates every repo owes. -# Copy into the repo root alongside ../hub-fetch-run.py. +# 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). # Install and enable with `uv tool install pre-commit` once, then `pre-commit install`. # The type checker and ruff hooks run via uvx, native tooling, no Docker. @@ -10,7 +10,7 @@ # A pip-form repo whose root the validator's `python-directories` input declares may swap an entry for `.venv/bin/python -m ` where its requirements install that tool, matching CI. # Run that way, pyright also takes `--pythonpath .venv/bin/python`. # On Windows each of those interpreter paths is `.venv\Scripts\python.exe` instead. -# 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. +# 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: @@ -33,11 +33,11 @@ repos: pass_filenames: false - id: prose-gate name: prose/comment-style gate (diff-scoped, fetched fresh from ProjectTemplate@main) - entry: uv run --no-project python hub-fetch-run.py .github/actions/prose-gate/prose_lint.py . --diff HEAD + entry: uv run --no-project 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 --no-project python hub-fetch-run.py .github/actions/repo-gate/repo_gate.py --check eol + entry: uv run --no-project python 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 index 65e2b9487..98e747dca 100644 --- a/catalog/snippets/pre-commit/README.md +++ b/catalog/snippets/pre-commit/README.md @@ -19,7 +19,7 @@ the Husky.Net shape judges all of them. The pull request gate reads every line t `ruff-check` and `type-check` hooks and keeps the doc gates, which is what a Docker, config, or docs repo wires. -Copy `../hub-fetch-run.py` alongside `.pre-commit-config.yaml` (repo root) for the doc gates +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 that no tool keeps current goes stale by construction, and CI (this repo's own, and the hub's) is the backstop for a change diff --git a/docs/pre-commit-hooks-rollout.md b/docs/pre-commit-hooks-rollout.md index 5260489ff..adc65cb6e 100644 --- a/docs/pre-commit-hooks-rollout.md +++ b/docs/pre-commit-hooks-rollout.md @@ -29,12 +29,12 @@ the canonical shape: Husky.Net `pre-commit` framework (`catalog/snippets/pre-commit/`, for a repo without one and for any repo that prefers it). Both 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 +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`. +Copy and adapt the applicable catalog snippet plus `catalog/snippets/hub_fetch_run.py`. Enable it (`git config core.hooksPath .husky`, or `uv tool install pre-commit` once then `pre-commit install`). For the Husky.Net snippet specifically, also run `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. diff --git a/scripts/README.md b/scripts/README.md index 409918e52..b6a3787fe 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -64,7 +64,7 @@ The reusable prose gate reads optional exclusions from `.github/prose-gate-exclu The file set is what git tracks plus what it is not ignoring, and the diff scope counts an untracked file as added in full, so a new file is read before it is staged rather than after. That is the file set where git can describe the tree, and where it cannot the run warns and walks the filesystem instead, which applies no ignore rules and skips the generated roots by name. Everything a verdict rests on is read from the repository named on the command line: the rule set, the file set, the diff, and the repository-relative keys that join the last two. Reading any of them from the directory the process happens to stand in is what produced every false clean on record, including an absolute path argument that reported clean over a tree it had read in full. Each run states its scope on stderr, because a gate that read nothing prints what a gate with nothing to report prints. -That same line names the copy that produced the verdict, so a finding is attributable to a commit rather than to the gate in general. A repository reaches this gate at whatever hub commit its own workflow pins, so a rule that changed after that pin is a rule no local run has, and a finding raised by the older copy then reproduces nowhere. The composite action passes its own `owner/repo@ref` through `--provenance`, being the only source that knows the pin, since a checked-out action carries no history to read it from. `PROSE_GATE_PROVENANCE` plays the same role for a caller invoking the script directly. The extraction recipe in [`GOVERNANCE.md`][governance] "Running the Linters Locally (Known-Working Invocations)" sets it. `hub-fetch-run.py` also sets it, to `ptr727/ProjectTemplate@ (hub-fetch-run main)`, naming the `main` commit it fetched. When it cannot resolve that commit, `hub-fetch-run.py` sets `ptr727/ProjectTemplate@main (hub-fetch-run, commit unresolved)` instead. Failing both `--provenance` and that variable, the script's own checkout answers, and only where that checkout tracks the script. An untracked copy and a copy under no repository each report `unknown` rather than a plausible commit, and a tracked copy edited since its commit is named `-dirty`, because a well-formed wrong attribution sends the next investigation at a copy nobody ran. +That same line names the copy that produced the verdict, so a finding is attributable to a commit rather than to the gate in general. A repository reaches this gate at whatever hub commit its own workflow pins, so a rule that changed after that pin is a rule no local run has, and a finding raised by the older copy then reproduces nowhere. The composite action passes its own `owner/repo@ref` through `--provenance`, being the only source that knows the pin, since a checked-out action carries no history to read it from. `PROSE_GATE_PROVENANCE` plays the same role for a caller invoking the script directly. The extraction recipe in [`GOVERNANCE.md`][governance] "Running the Linters Locally (Known-Working Invocations)" sets it. `hub_fetch_run.py` also sets it, to `ptr727/ProjectTemplate@ (hub-fetch-run main)`, naming the `main` commit it fetched. When it cannot resolve that commit, `hub_fetch_run.py` sets `ptr727/ProjectTemplate@main (hub-fetch-run, commit unresolved)` instead. Failing both `--provenance` and that variable, the script's own checkout answers, and only where that checkout tracks the script. An untracked copy and a copy under no repository each report `unknown` rather than a plausible commit, and a tracked copy edited since its commit is named `-dirty`, because a well-formed wrong attribution sends the next investigation at a copy nobody ran. In Markdown an HTML comment carrying no sentence punctuation is treated as a structural marker rather than commentary, so it takes neither a capital nor a sentence split. The reference-link group headers, the ToC-omit directive, and the `agent-safety` install markers are each matched verbatim by a tool, so rewriting one to satisfy the rule breaks whatever reads it. A Markdown comment that does punctuate a sentence is prose and is judged as prose. diff --git a/spec/audit.py b/spec/audit.py index 05af38599..9952f07b2 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -314,7 +314,8 @@ def expected_python_directories(entry, tree): # The catalog's hook snippets carry this helper into repos of every language, so it is fleet tooling rather than the repo's own Python. -CARRIED_PYTHON_HELPERS = {"hub-fetch-run.py"} +# A repo that copied it before the rename keeps the hyphenated name until it is next resynced. +CARRIED_PYTHON_HELPERS = {"hub_fetch_run.py", "hub-fetch-run.py"} def declared_types(entry): @@ -6437,10 +6438,16 @@ def fake(path, ok404=False): ({"types": None}, {"tools/script.py"}, 1, "a malformed types value reads as untyped"), ( {"types": ["docs"]}, - {".husky/hub-fetch-run.py"}, + {".husky/hub_fetch_run.py"}, 0, "the carried hook helper is not the repo's Python", ), + ( + {"types": ["docs"]}, + {".husky/hub-fetch-run.py"}, + 0, + "the carried hook helper under its former name is not the repo's Python", + ), ( { "types": ["docs"], @@ -6536,7 +6543,7 @@ def fake(path, ok404=False): python_directory_cases.append( ( {"pythonDirectories": ["Tools"]}, - {"Tools/a.py", "hub-fetch-run.py"}, + {"Tools/a.py", "hub_fetch_run.py"}, 0, "the carried hook helper sits outside every directory", ) diff --git a/spec/project-types.json b/spec/project-types.json index 3ce40d139..d4564fd5c 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -35,7 +35,7 @@ "detect": ["*.py"], "profiles": ["build", "lint-only"], "canonicalPlacement": "pyproject.toml", - "profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies or as the repo's deliverable - in its uv form a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --all-groups --frozen + uv run in CI), and in its pip form a pyproject.toml beside a requirements*.txt with no uv.lock, installed with pip. The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by trait, whether the Python has third-party runtime dependencies or is the repo's deliverable, which the audit detects structurally from pyproject.toml, uv.lock, and requirements*.txt (see python.profile.detect) rather than by inspecting imports. The shape [project]+deps/[build-system] + uv.lock is build, and tool-config-only with no [project]/[build-system], no uv.lock, and no requirements*.txt is lint-only. The declared profile corresponds to this shape, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile). The profile decides only how the tools are installed, never what is owed. Every Python directory owes lint, format, a type check, a tests/ suite, and coverage, and a repository declares each in the hub validator's python-directories input, which the registry's pythonDirectories mirrors (python.directories.declared). Tracked .py files other than the catalog's carried hub-fetch-run.py hook helper, not a config file, are what make a repository Python.", + "profileNote": "The declared profile is build or lint-only, each with a structural pyproject.toml shape (CODESTYLE.md Python 'Two profiles'). The build profile (structurally the PROJECT shape) is Python with third-party runtime dependencies or as the repo's deliverable - in its uv form a PEP 621 uv project (pyproject [project]+deps+[build-system], committed uv.lock, uv sync --all-groups --frozen + uv run in CI), and in its pip form a pyproject.toml beside a requirements*.txt with no uv.lock, installed with pip. The lint-only profile (structurally the SCRIPTS shape) is stdlib-only utility scripts embedded in a non-Python repo (e.g. a Python tooling subtree of a csharp app) - run with uvx, no uv.lock, no uv project, pyproject carries only [tool.ruff]/[tool.mypy] config. The two differ by trait, whether the Python has third-party runtime dependencies or is the repo's deliverable, which the audit detects structurally from pyproject.toml, uv.lock, and requirements*.txt (see python.profile.detect) rather than by inspecting imports. The shape [project]+deps/[build-system] + uv.lock is build, and tool-config-only with no [project]/[build-system], no uv.lock, and no requirements*.txt is lint-only. The declared profile corresponds to this shape, and each check names the minimum profile it needs (see python.profile.detect and each check's minProfile). The profile decides only how the tools are installed, never what is owed. Every Python directory owes lint, format, a type check, a tests/ suite, and coverage, and a repository declares each in the hub validator's python-directories input, which the registry's pythonDirectories mirrors (python.directories.declared). Tracked .py files other than the catalog's carried hub_fetch_run.py hook helper or its former name hub-fetch-run.py, not a config file, are what make a repository Python.", "checks": [ { "id": "python.profile.detect", "verdict": "letter", "assert": "The declared profile corresponds to the pyproject.toml shape. A [project] table with runtime dependencies (or a [build-system]) is the build profile (the PROJECT shape). A pyproject.toml beside a requirements*.txt is the build profile too, installed with pip, whether or not it carries a [project] table. A pyproject carrying only [tool.*] config with no [project]/[build-system], no uv.lock, and no requirements*.txt is the lint-only profile (the SCRIPTS shape). A lint-only subtree must not carry a uv.lock or project/build metadata, which would misrepresent it as a shippable package, and a build one must carry one of those or a requirements*.txt. A lint-only subtree carries no requirements*.txt either. A virtual uv workspace root, whose root pyproject.toml carries [tool.uv.workspace] with no [project] or [build-system] and commits a uv.lock, is the build shape, its [project] tables living in the workspace members under subtrees.", "intentRef": "CODESTYLE.md" }, { "id": "python.ruff.config", "verdict": "intent", "assert": "A ruff configuration is present (pyproject.toml [tool.ruff]). Both profiles.", "intentRef": "CODESTYLE.md" }, @@ -43,7 +43,7 @@ { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json / mypy.ini / .mypy.ini, or a setup.cfg [mypy] section, is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker or as the only one, not banned. It is required for a Home Assistant integration (platinum strict-typing), is the lint-only profile's type checker, and may be a build directory's only CI checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, { "id": "python.coverage.codecov", "verdict": "letter", "assert": "Every Python directory with tests runs them under coverage and uploads the report to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). A directory with a uv.lock or a requirements*.txt runs pytest --cov-report=xml, and because --cov-report=xml alone measures nothing and writes no file, it declares pytest-cov among its test dependencies, a dev dependency group in a uv project and a requirements*.txt entry on pip, and selects the coverage source in its own pyproject.toml, an addopts --cov= entry in practice. A lint-only directory runs coverage run -m unittest discover -s tests, then coverage xml. The validator's Python leg runs in each directory the caller declares in python-directories, or at the root where it declares none and a root pyproject.toml is tracked. It deletes the directory's coverage.xml before the run and fails the test step when the run did not write it, and it fails a declared directory that has no tests/. CODECOV_TOKEN is stored in both the repo actions and dependabot secrets stores, the second so the upload does not skip on a Dependabot PR, and the caller maps it to the reusable validator by name. N/A for a repo carrying no tests for this type, which the validator permits only at an undeclared root. In a mixed repo the codecov.yml (or .codecov.yml) file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md" }, - { "id": "python.directories.declared", "verdict": "letter", "assert": "A repository carrying a tracked .py file other than the catalog's carried hub-fetch-run.py declares the python type, or suppresses that discovery advisory with a driftNote naming (python.directories.declared) and its reason, per spec/type-model.md. Every such file sits in a directory the registry's pythonDirectories names, or anywhere under the root where it names none and a root pyproject.toml is tracked. Every caller of the hub's validate-task.yml passes exactly those directories in its python-directories input, as a literal (|) block or a single value, so the audit can see a missing declaration the validator itself only warns about. A root tests/ suite with no root uv.lock or requirements*.txt is never run by the undeclared root default, so a lint-only root declares '.' and a root declaring [project] adds a manifest. spec/audit.py checks each of these mechanically.", "intentRef": "WORKFLOW.md" }, + { "id": "python.directories.declared", "verdict": "letter", "assert": "A repository carrying a tracked .py file other than the catalog's carried hub_fetch_run.py or its former name hub-fetch-run.py declares the python type, or suppresses that discovery advisory with a driftNote naming (python.directories.declared) and its reason, per spec/type-model.md. Every such file sits in a directory the registry's pythonDirectories names, or anywhere under the root where it names none and a root pyproject.toml is tracked. Every caller of the hub's validate-task.yml passes exactly those directories in its python-directories input, as a literal (|) block or a single value, so the audit can see a missing declaration the validator itself only warns about. A root tests/ suite with no root uv.lock or requirements*.txt is never run by the undeclared root default, so a lint-only root declares '.' and a root declaring [project] adds a manifest. spec/audit.py checks each of these mechanically.", "intentRef": "WORKFLOW.md" }, { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "Build profile: the committed uv.lock resolves to LF through the repository-wide .editorconfig and .gitattributes defaults. A CRLF-native operational repo adds a narrow uv.lock LF override only if it adopts the uv build profile. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the lint-only profile (no uv.lock by definition).", "intentRef": "GOVERNANCE.md#line-endings", "minProfile": "build" }, { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx @ pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile, whose uv form pins tool versions via uv.lock + uv sync --frozen instead, and whose pip form takes a tool's version from the requirements*.txt that Dependabot's pip ecosystem bumps where a directory declared in python-directories installs that tool into its .venv.", "intentRef": "CODESTYLE.md" } ] @@ -177,7 +177,7 @@ { "id": "parity.markdownlint", "verdict": "letter", "assert": "One .markdownlint-cli2.jsonc at the repository root drives the VS Code extension, the CLI, and CI, and CI runs it. This check counts that root config only, so a nested .markdownlint-cli2.jsonc is not a second one.", "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 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 hub repo itself is the one exception, calling its own scripts/prose_lint.py and scripts/repo_gate.py directly, since it has no separate hub to fetch from. 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" } + { "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 or its former name hub-fetch-run.py. The hub repo itself is the one exception, calling its own scripts/prose_lint.py and scripts/repo_gate.py directly, since it has no separate hub to fetch from. 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": { diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py index ada2d1f8a..d89819b7f 100755 --- a/tests/test_cli_usage.py +++ b/tests/test_cli_usage.py @@ -120,7 +120,7 @@ ) REFUSES_A_LEADING_FLAG = { - "catalog/snippets/hub-fetch-run.py": "forwards every argument after its hub path", + "catalog/snippets/hub_fetch_run.py": "forwards every argument after its hub path", } REFUSED_AS_OPTION = re.compile( @@ -509,7 +509,7 @@ def test_discovery_finds_each_kind(self) -> None: "repo-config/configure.sh", "scripts/handoff.py", "scripts/prose_lint.py", - "catalog/snippets/hub-fetch-run.py", + "catalog/snippets/hub_fetch_run.py", "host-setup/bootstrap.ps1", *BEFORE_FLAG, ): diff --git a/tests/test_hub_fetch_run.py b/tests/test_hub_fetch_run.py index ef9800730..3f206e043 100755 --- a/tests/test_hub_fetch_run.py +++ b/tests/test_hub_fetch_run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Drive hub-fetch-run.py's unborn-HEAD rewrite, probe-failure path, and provenance handoff. +"""Drive hub_fetch_run.py's unborn-HEAD rewrite, probe-failure path, and provenance handoff. The git cases run in constructed temp repositories, and the snippet bounds its own git probe with a timeout. The network is stubbed, so nothing here reaches GitHub. @@ -25,7 +25,7 @@ from unittest import mock ROOT = Path(__file__).resolve().parent.parent -SNIPPET = ROOT / "catalog" / "snippets" / "hub-fetch-run.py" +SNIPPET = ROOT / "catalog" / "snippets" / "hub_fetch_run.py" TIMEOUT = 30 SHA = "0123456789abcdef0123456789abcdef01234567" diff --git a/tests/test_python_directories.py b/tests/test_python_directories.py index 764212236..0fbb897d6 100755 --- a/tests/test_python_directories.py +++ b/tests/test_python_directories.py @@ -23,6 +23,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / ".github/actions/python-directories")) import python_directories as pd +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "spec")) +import audit + class ResolveTests(unittest.TestCase): def test_empty_declaration_takes_the_root_project(self) -> None: @@ -117,9 +120,18 @@ def test_a_subdirectory_covers_only_its_own_files(self) -> None: self.assertEqual(pd.uncovered(["Tools"], tracked), ["Other/b.py", "ToolsX/c.py"]) def test_the_carried_hook_helper_is_not_the_repo_s_python(self) -> None: - tracked = ["hub-fetch-run.py", ".husky/hub-fetch-run.py", "Tools/a.py"] + tracked = [ + "hub_fetch_run.py", + ".husky/hub_fetch_run.py", + "hub-fetch-run.py", + ".husky/hub-fetch-run.py", + "Tools/a.py", + ] self.assertEqual(pd.uncovered(["Tools"], tracked), []) + def test_the_audit_exempts_the_same_helper_names(self) -> None: + self.assertEqual(pd.CARRIED_HELPERS, audit.CARRIED_PYTHON_HELPERS) + def test_no_directory_leaves_every_python_file_uncovered(self) -> None: self.assertEqual(pd.uncovered([], ["a.py", "b.txt"]), ["a.py"]) From 48c1e7638e5b8f08d717ba011dec22d4a0400b99 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 19 Oct 2026 01:10:19 -0700 Subject: [PATCH 2/3] Print a Clean Verdict's Headline as an Advisory Line in pr_review.py (#3221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements the maintainer's decision on #2567, "print it, do not gate". A clean-verdict Copilot round can state no open finding, open no thread, and collapse no suppressed or previously-missed block while its headline sentence still names a remaining nit. `pr_review.py` read every such round as a clean pass and printed nothing. - `uncounted_verdict` takes the verdict set as a parameter, so the flagging reading behind exit `50` and the new clean reading share one guard. - The digest prints a clean round's headline under `CLEAN VERDICT HEADLINE`. The line is advisory and changes no exit code. It does not try to tell a new nit from one already answered, per the decision. - The usage text, `scripts/README.md`, and `pr-review-conduct` describe the advisory line, with the skill distributions regenerated. ## Tests - A clean verdict whose headline names findings prints the headline, and `status` and `wait` each exit `0`. Each command's output is read on its own. - A clean headline naming an answered thread prints and still exits `0`. - A clean verdict with no headline prose prints no advisory. - A flagging verdict prints no advisory and still exits `50`. Run against the base `pr_review.py`, the two positive tests fail and the two negative tests pass. The full `tests.test_pr_review` suite passes, as do ruff, mypy, prose_lint (including `--check sentence-length`), the eol gate, `spec/validate.py`, `build_dist.py --check`, markdownlint, cspell, and editorconfig-checker. ## Local strict review There were three recorded passes. Round 1 found an overstated exit-code claim on three surfaces, a docstring summary made false by the new parameter, a test docstring with a false causal claim, a merged output buffer, and two long sentences. Round 2 found a pronoun that bound to the wrong noun. Each was fixed. Round 3 found nothing, so no `introduced` finding remains open. Refs #2567 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/pr-review-conduct/SKILL.md | 4 ++- .../.source-digests/pr-review-conduct | 2 +- .../skills/pr-review-conduct/SKILL.md | 4 ++- .github/skills/pr-review-conduct/SKILL.md | 4 ++- scripts/README.md | 2 +- scripts/pr_review.py | 21 ++++++++++-- tests/test_pr_review.py | 34 ++++++++++++++++++- 7 files changed, 62 insertions(+), 9 deletions(-) diff --git a/.agents/skills/pr-review-conduct/SKILL.md b/.agents/skills/pr-review-conduct/SKILL.md index dacab1446..831dbfd86 100644 --- a/.agents/skills/pr-review-conduct/SKILL.md +++ b/.agents/skills/pr-review-conduct/SKILL.md @@ -139,7 +139,9 @@ visible comments, routinely still carries a finding nobody has answered. Treatin clean one that counts no finding prints its headline under `VERDICT WITH NO COUNTED FINDING`, and `status` and `wait` exit `50`. The headline is the one place that round names what it flags, so answer it as a suppressed finding. - The exit code repeats on that head after the answer. + The exit code repeats on that head after the answer. A clean verdict on the same terms prints + its headline under `CLEAN VERDICT HEADLINE`, which changes no exit code. Read that headline, + since it can name a nit no thread carries, and answer any such nit with `comment`. The body read in that format so far carried no `Suppressed comments` heading, so `suppressed=` finds nothing in it and there is no collapsed block to quote a count from: the digest's shortfall and the body's own prose are what an answer cites instead. A later body that does diff --git a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct index 656d1368f..c9f590419 100644 --- a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct +++ b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct @@ -1 +1 @@ -8e3f5e82dbdaf90c +9466ae9eb9866c94 diff --git a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md index dacab1446..831dbfd86 100644 --- a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md @@ -139,7 +139,9 @@ visible comments, routinely still carries a finding nobody has answered. Treatin clean one that counts no finding prints its headline under `VERDICT WITH NO COUNTED FINDING`, and `status` and `wait` exit `50`. The headline is the one place that round names what it flags, so answer it as a suppressed finding. - The exit code repeats on that head after the answer. + The exit code repeats on that head after the answer. A clean verdict on the same terms prints + its headline under `CLEAN VERDICT HEADLINE`, which changes no exit code. Read that headline, + since it can name a nit no thread carries, and answer any such nit with `comment`. The body read in that format so far carried no `Suppressed comments` heading, so `suppressed=` finds nothing in it and there is no collapsed block to quote a count from: the digest's shortfall and the body's own prose are what an answer cites instead. A later body that does diff --git a/.github/skills/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md index dacab1446..831dbfd86 100644 --- a/.github/skills/pr-review-conduct/SKILL.md +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -139,7 +139,9 @@ visible comments, routinely still carries a finding nobody has answered. Treatin clean one that counts no finding prints its headline under `VERDICT WITH NO COUNTED FINDING`, and `status` and `wait` exit `50`. The headline is the one place that round names what it flags, so answer it as a suppressed finding. - The exit code repeats on that head after the answer. + The exit code repeats on that head after the answer. A clean verdict on the same terms prints + its headline under `CLEAN VERDICT HEADLINE`, which changes no exit code. Read that headline, + since it can name a nit no thread carries, and answer any such nit with `comment`. The body read in that format so far carried no `Suppressed comments` heading, so `suppressed=` finds nothing in it and there is no collapsed block to quote a count from: the digest's shortfall and the body's own prose are what an answer cites instead. A later body that does diff --git a/scripts/README.md b/scripts/README.md index b6a3787fe..828f451fd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -229,7 +229,7 @@ Copilot's reviews arrive in two body formats. None of the ten bodies read here i One reader, `read_overview`, reads every section of that format's overview. Each section it added arrived as a spelling nothing read, and each stopped the loop at exit `43` in turn. The `OVERVIEW_SECTIONS` table in the script names each collapsible section that lists thread links, with its role. The open-findings section states the later revision's total, since that revision writes the bare zero line only for a zero. So where the preamble states no total, that section's count is `T`. `Open (N)` is not read as a total, since it listed an earlier round's thread in 2 of the 41 rounds measured. Each open-section entry links the database id of its thread's first comment. An entry whose title links no thread on the pull request is a finding named only in the body. `FINDINGS WITH NO THREAD` counts it whatever the totals say. It also counts each entry an `Open (N)` section counts beyond those it lists with a link. A linked entry is a bullet at the section's margin carrying a link this reader recognizes. A bullet inside any block collapsed within the section is not one. Any other entry counts here too. Where the open-findings section supplied `T`, the block is measured against the entries it lists with a link rather than against `M`. So an entry linking an earlier round's thread is no finding without one. A finding that section's count names and no entry links is still counted. Entries are counted rather than links, so a back-reference beside an entry's own link covers no other entry. A resolved section's entries link threads an earlier round raised, so they are read as information rather than findings. -`status` and `wait` both exit `50` where the head's round opens on a verdict other than the clean one and counts no finding. It states none, lists none in an open section, opened no thread, and collapsed none, and no reviewer's thread is open. Every count reads that round as a clean pass, so the digest prints its headline under `VERDICT WITH NO COUNTED FINDING`. Over the rounds read here in that format, most such headlines named a finding and the rest a caution about the change. Only reading the headline tells the two apart, so it takes the triage a suppressed finding gets, fixed or answered with `comment`. The code repeats on that head after the answer, since nothing here reads one. An open thread defers the reading rather than cancelling it, since the headline may name that thread. The code ranks under `42`, `43`, and `45`, a part-reviewed diff being the larger gap. It outranks `wait`'s `44`, so on such a head a stuck check shows only in the digest. +`status` and `wait` both exit `50` where the head's round opens on a verdict other than the clean one and counts no finding. It states none, lists none in an open section, opened no thread, and collapsed none, and no reviewer's thread is open. Every count reads that round as a clean pass, so the digest prints its headline under `VERDICT WITH NO COUNTED FINDING`. Over the rounds read here in that format, most such headlines named a finding and the rest a caution about the change. Only reading the headline tells the two apart, so it takes the triage a suppressed finding gets, fixed or answered with `comment`. The code repeats on that head after the answer, since nothing here reads one. An open thread defers the reading rather than cancelling it, since the headline may name that thread. The code ranks under `42`, `43`, and `45`, a part-reviewed diff being the larger gap. It outranks `wait`'s `44`, so on such a head a stuck check shows only in the digest. A clean verdict on the same terms prints its headline under `CLEAN VERDICT HEADLINE`, and that line changes no exit code. About a quarter of the clean headlines measured named a remaining nit. Nothing in the body says whether that nit is new or a thread already answered, so the line is advisory. `comment` posts one answer in the pull request conversation. A suppressed finding has no thread, so this is the path that records its disposition. The command reads the pull request node ID in the same run and exposes no ID argument. It confirms the returned comment URL and exact body before it reports success. A missing target exits `65`, and an unconfirmed response exits `66`. An unconfirmed response may still have landed, so its output tells the reader to inspect the conversation before retrying. diff --git a/scripts/pr_review.py b/scripts/pr_review.py index df02722cd..7456991f3 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -138,6 +138,8 @@ and over `wait`'s 44. The headline is owed the triage a suppressed finding gets, and the code repeats on that head after it is answered, since nothing here reads an answer. + A clean verdict on the same terms prints its headline under `CLEAN VERDICT HEADLINE`. + That line is advisory and changes no exit code, since it may name an answered nit. A refusal naming the account quota still reads as absent here, exit 0, since a refusal covers no head either. Its printed digest line carries `refusal=QUOTA` regardless, and `refusal=ERROR` for an error refusal whose run log names no rate limit or could not be @@ -3070,8 +3072,8 @@ def fits(finding: int, thread: int) -> bool: return unmatched(len(entries), len(threads), fits) -def uncounted_verdict(pr: dict) -> tuple[str, str] | None: - """The head round's verdict and headline, where the verdict flags what nothing counts. +def uncounted_verdict(pr: dict, verdicts: set[str] = FLAGGING_VERDICTS) -> tuple[str, str] | None: + """The head round's verdict in `verdicts` and its headline, where that round counts no finding. A round opening on a flagging verdict that states no open finding, lists none in an open section, opens no thread, and collapses no suppressed or previously-missed finding names @@ -3082,13 +3084,17 @@ def uncounted_verdict(pr: dict) -> tuple[str, str] | None: An open thread defers the reading rather than cancelling it, since the headline may name that thread and the loop already waits on it, and once it is resolved the reading returns. + + Passed `CLEAN_VERDICTS`, it reads a clean round on the same terms, whose headline named a + remaining nit in about a quarter of the rounds measured. That reading is printed and never + gated, since nothing in the body says whether the nit is new or an answered thread. """ newest = second_format_head(pr) if newest is None: return None body = newest.get("body") or "" verdict, headline, sections = read_overview(body) - if not verdict or unvetted(verdict, FLAGGING_VERDICTS): + if not verdict or unvetted(verdict, verdicts): return None listed = any(role != RESOLVED and (count or ids) for role, count, ids, _ in sections) if listed or stated_total(body) or round_threads(pr, newest): @@ -4955,6 +4961,7 @@ def digest( max(headlined - headline_listed, 0), ) flagged = uncounted_verdict(pr) + advisory = uncounted_verdict(pr, CLEAN_VERDICTS) answer = answered_outside_review(pr) refusal = stopping_refusal(pr) or (None if on_head else refusing_review(pr)) @@ -5171,6 +5178,14 @@ def digest( "flags is named. Give the headline the triage a suppressed finding gets" ) lines.append(f" {flagged[1] or '(the headline carries no prose)'}") + if advisory and advisory[1]: + lines.append( + f" CLEAN VERDICT HEADLINE: the round covering the head opens on `{advisory[0]}` and " + "counts no finding. Its headline may name a remaining nit, new or already answered, " + "which nothing here tells apart, so this is advisory and changes no exit code. Read " + "it before closing the review loop" + ) + lines.append(f" {advisory[1]}") if carried is not None: # Printed whatever the carried state is, the provenance being the same question either way. # A carried partial then prints its own block under this one. diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index 4e204a288..8b0cf4dbd 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -5548,9 +5548,41 @@ def test_a_collapsed_finding_cancels_the_reading(self) -> None: self.assertIsNone(pr_review.uncounted_verdict(payload([review(body=body)]))) def test_a_clean_verdict_counting_no_finding_exits_zero(self) -> None: + """Any clean headline prints as advisory, here one naming findings, and the exit stays 0.""" self.answer(payload([review(body=self.headline("### Approval recommended"))])) + for command in ("status", "wait"): + with self.subTest(command=command), mock.patch.object(pr_review.time, "sleep"): + self.out.seek(0) + self.out.truncate() + self.assertEqual(0, pr_review.main([command, "7", "--repo", "o/r"])) + printed = self.out.getvalue() + self.assertNotIn("VERDICT WITH NO COUNTED FINDING", printed) + self.assertIn("CLEAN VERDICT HEADLINE", printed) + self.assertIn("Resolve the two outstanding parser-retry", printed) + + def test_a_clean_headline_naming_an_answered_thread_still_reads_clean(self) -> None: + """Nothing tells an answered thread from a new nit, so both print and neither gates.""" + body = self.headline("### Ready to approve").replace( + "Resolve the two outstanding parser-retry and timeout-handling findings.", + "The earlier retry-loop finding is answered in its thread.", + ) + self.answer(payload([review(body=body)], [thread("T1", resolved=True)])) self.assertEqual(0, pr_review.main(["status", "7", "--repo", "o/r"])) - self.assertNotIn("VERDICT WITH NO COUNTED FINDING", self.out.getvalue()) + self.assertIn("The earlier retry-loop finding is answered", self.out.getvalue()) + + def test_a_clean_verdict_with_no_headline_prose_prints_no_advisory(self) -> None: + body = self.headline("### Approval recommended").replace( + "Resolve the two outstanding parser-retry and timeout-handling findings.", "" + ) + self.answer(payload([review(body=body)])) + self.assertEqual(0, pr_review.main(["status", "7", "--repo", "o/r"])) + self.assertNotIn("CLEAN VERDICT HEADLINE", self.out.getvalue()) + + def test_a_flagging_verdict_prints_no_advisory(self) -> None: + """The flagging reading already prints that headline, and gates on it.""" + self.answer(payload([review(body=self.headline())])) + self.assertEqual(50, pr_review.main(["status", "7", "--repo", "o/r"])) + self.assertNotIn("CLEAN VERDICT HEADLINE", self.out.getvalue()) def test_a_flagging_verdict_with_counted_findings_is_not_this_reading(self) -> None: rd = review(body=revised_with(open_section(("6000000001",))), rid="PRR_head") From 83e291c215648617c5d6ac869caf5487fb7b4709 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:44:17 +0000 Subject: [PATCH 3/3] Bump streetsidesoftware/cspell-action from 9.1.0 to 9.2.0 in the actions-deps group across 1 directory (#3223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the actions-deps group with 1 update in the / directory: [streetsidesoftware/cspell-action](https://github.com/streetsidesoftware/cspell-action). Updates `streetsidesoftware/cspell-action` from 9.1.0 to 9.2.0
Release notes

Sourced from streetsidesoftware/cspell-action's releases.

v9.2.0

9.2.0 (2026-10-14)

Features

Updates and Bug Fixes

  • Workflow Bot -- Update ALL Dependencies (main) (#2760) (21cf57f)
Changelog

Sourced from streetsidesoftware/cspell-action's changelog.

Changelog

All notable changes to this project will be documented in this file. See standard-version for commit guidelines.

9.2.0 (2026-10-14)

Features

Updates and Bug Fixes

  • Workflow Bot -- Update ALL Dependencies (main) (#2760) (21cf57f)

9.1.0 (2026-09-04)

Features

Updates and Bug Fixes

9.0.1 (2026-08-15)

Updates and Bug Fixes

  • make sure the dictionaries are up to date. (#2733) (ed61a9e)

9.0.0 (2026-08-15)

âš  BREAKING CHANGES

  • Update CSpell version (10.0.0) (#2675)

Features

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=streetsidesoftware/cspell-action&package-manager=github_actions&previous-version=9.1.0&new-version=9.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate-task.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 42f53a1ad..f18fa0dd2 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -147,7 +147,7 @@ jobs: # The spell check covers README + HISTORY only, per CODESTYLE.md "Markdown and Spelling". - name: Spell check step - uses: streetsidesoftware/cspell-action@6f3c77c1406bc930f944ba97e9801a22e42caf58 # v9.1.0 + uses: streetsidesoftware/cspell-action@ce1816e4ce36532956ac6e983737392774b6e169 # v9.2.0 with: files: | README.md