From ab0ac31a29dc231fc1a5f6d863b2d36c5636572e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 21:22:28 -0700 Subject: [PATCH 01/24] Let an Attested Local Pass Cover a Head Beside a Table-Less Copilot Round (#2765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary On a pull request into a branch other than the default, a Copilot round on the head that states no coverage, has none carried to it, and carries no file table no longer displaces an attested local pass. `status` and `wait` now read `review_on_head=yes coverage=local` there and exit 0, rather than `coverage=unstated` and exit 45. The attested reading stays narrow: - A Copilot round on record that states or appears to state partial coverage still blocks it, per Merge Gate item 2. - A head round that carries its own file table still blocks it, even one naming fewer files than the diff changed, since that table is the round's own word that it skipped a file. - A promotion, a truncated review history, and a refusal on the head still block it, as before. The help text, `scripts/README.md`, `pr-review-conduct` Merge Gate item 2, and `.github/copilot-instructions.md` now name `coverage=local`, the field both attested readings share. ## Tests - Table-less head round plus attestation: `status` and `wait` exit 0 with `coverage=local`. - The same head with no attestation still exits 45. - A partial-coverage round on record plus attestation is not covered. - A head round whose table skips a changed file plus attestation stays at 45. Each new test fails against the code before its fix. The full suite passes (`python3 -m unittest discover -s tests`). ## Local strict review Three recorded passes ran. Every `introduced` finding was fixed, except one declined with evidence: `wait` on an attested head beside a round returns 0 while checks are still running. That matches what an ordinary Copilot-covered head already does. Only the held path, where `wait` requested nothing, polls checks. No finding is left open. Closes on promotion: #2739 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/pr-review-conduct/SKILL.md | 2 +- .../.source-digests/pr-review-conduct | 2 +- .../skills/pr-review-conduct/SKILL.md | 2 +- .github/copilot-instructions.md | 2 +- .github/skills/pr-review-conduct/SKILL.md | 2 +- scripts/README.md | 2 +- scripts/pr_review.py | 40 ++++++++++--- tests/test_pr_review.py | 56 +++++++++++++++++++ 8 files changed, 93 insertions(+), 15 deletions(-) diff --git a/.agents/skills/pr-review-conduct/SKILL.md b/.agents/skills/pr-review-conduct/SKILL.md index cc6486009..9d1aa39d9 100644 --- a/.agents/skills/pr-review-conduct/SKILL.md +++ b/.agents/skills/pr-review-conduct/SKILL.md @@ -39,7 +39,7 @@ visible comments, routinely still carries a finding nobody has answered. Treatin thread, and a missing approval alike, and the response differs by cause. 2. A review is confirmed on the **current head SHA**. On a pull request into a branch other than the default, a head after Copilot's first round can carry an attested local pass instead, - `review_on_head=local` in the digest, where the whole review history is in view and no round on + `coverage=local` in the digest, where the whole review history is in view and no round on record states or appears to state partial coverage, and a pull request into the default branch, a promotion among them, always carries a Copilot round on its head. A review is matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the diff --git a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct index 54f94dec3..bc78a3d44 100644 --- a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct +++ b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct @@ -1 +1 @@ -7407d2839e84376f +3ec41bd618c55867 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 cc6486009..9d1aa39d9 100644 --- a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md @@ -39,7 +39,7 @@ visible comments, routinely still carries a finding nobody has answered. Treatin thread, and a missing approval alike, and the response differs by cause. 2. A review is confirmed on the **current head SHA**. On a pull request into a branch other than the default, a head after Copilot's first round can carry an attested local pass instead, - `review_on_head=local` in the digest, where the whole review history is in view and no round on + `coverage=local` in the digest, where the whole review history is in view and no round on record states or appears to state partial coverage, and a pull request into the default branch, a promotion among them, always carries a Copilot round on its head. A review is matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 99a873763..005073974 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,7 +28,7 @@ For every review: The review automation is `scripts/pr_review.py`, run from a hub checkout. Use its `status`, `wait`, `attest`, `comment`, and `reply --resolve` commands instead of reconstructing GraphQL queries or copying review identifiers by hand. Use `comment` for a suppressed-finding answer in the pull request conversation. Its status gate verifies the current head, diff coverage, output shape, inline threads, body-only findings, and required checks. -A formal review with no findings is complete only when it covers the current head and full diff coverage of the change set that head has is stated, or read from a file table as below. The round covering the head states it, or the newest round that states it at all does and the pull request changes the same set of files at both commits, which is the only condition under which a statement carries forward. Only that newest round is consulted, so an older round whose change set does match carries nothing. Where no statement reaches the head either way, a round covering it whose own file table names exactly the changed files covers it, which is the reading a round at Balanced review effort gives, and a statement that reaches the head, stated on it or carried to it, wins over that table. Where no round covering the head carries a table of its own, the newest round that does, its table naming exactly the changed files, stands in under the bound a statement carries under, the pull request changing the same set of files at both commits. The table stands in only where no Copilot round on the pull request, on any commit, states or appears to state partial coverage, so a pull request that ever had a partial round goes to the maintainer. A round reporting partial coverage of the diff blocks the merge, and so does a refusal, a coverage statement that does not reach this head, meaning absent from every round or carried by none because the change set moved or could not be compared, with no file table standing in for it, an unrecognized output shape, an unresolved thread, or a body-only finding. On a pull request into a branch other than the default, a fix push after Copilot's first round is covered by an attested local pass rather than another Copilot round, `review_on_head=local` in the digest, while a pull request into the default branch, a promotion among them, always carries a Copilot round on its head. Re-run the loop after every fix push. Never infer review completion from `mergeStateStatus: CLEAN`. +A formal review with no findings is complete only when it covers the current head and full diff coverage of the change set that head has is stated, or read from a file table as below. The round covering the head states it, or the newest round that states it at all does and the pull request changes the same set of files at both commits, which is the only condition under which a statement carries forward. Only that newest round is consulted, so an older round whose change set does match carries nothing. Where no statement reaches the head either way, a round covering it whose own file table names exactly the changed files covers it, which is the reading a round at Balanced review effort gives, and a statement that reaches the head, stated on it or carried to it, wins over that table. Where no round covering the head carries a table of its own, the newest round that does, its table naming exactly the changed files, stands in under the bound a statement carries under, the pull request changing the same set of files at both commits. The table stands in only where no Copilot round on the pull request, on any commit, states or appears to state partial coverage, so a pull request that ever had a partial round goes to the maintainer. A round reporting partial coverage of the diff blocks the merge, and so does a refusal, a coverage statement that does not reach this head, meaning absent from every round or carried by none because the change set moved or could not be compared, with no file table or attested local pass standing in for it, an unrecognized output shape, an unresolved thread, or a body-only finding. On a pull request into a branch other than the default, a fix push after Copilot's first round is covered by an attested local pass rather than another Copilot round, `coverage=local` in the digest, while a pull request into the default branch, a promotion among them, always carries a Copilot round on its head. Re-run the loop after every fix push. Never infer review completion from `mergeStateStatus: CLEAN`. Review effort is user-controlled. The automation observes `Lite`, `Balanced`, or `Max`, including an inherited `Default ()`, and never selects or changes the setting. Effort does not determine coverage or completion. A request can complete without a `copilot_work_started` event, so absence of that event is not a stalled-review verdict. When `wait` returns `PENDING` with `requested=yes`, report the state and rerun `wait` for another bounded interval by default, reading that field as acceptance of the request rather than as delivery of a round. Do not clear the request on that first timeout, because it may still be active. Where a second bounded wait times out as well, read the pending set, clear it only where no human or team reviewer is requested alongside the bot, and rerun `wait`, which then has nothing outstanding to defer to and requests afresh, or polls and says so on its own auto-request line where it finds no reviewer node id to request with. The clear replaces that set rather than adding to it and nothing restores a request it drops, so a stall on a pull request that has a human or team reviewer requested goes to the maintainer, and so does one still pending after the wait that follows a clear. That clear is a recovery step the script does not implement, and `docs/pr-reviewer-reference.md`, in the hub checkout the script is run from, carries it. This recovery replaces only the review request and never changes the effort setting. A `wait` ending `REQUEST_NOT_RECORDED`, exit 48, is a different state and takes none of this recovery: the request returned success and left neither a pending reviewer nor a review-request event, which is how an exhausted Copilot allowance has shown itself, so clearing and requesting again does not clear it and it goes to the maintainer. diff --git a/.github/skills/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md index cc6486009..9d1aa39d9 100644 --- a/.github/skills/pr-review-conduct/SKILL.md +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -39,7 +39,7 @@ visible comments, routinely still carries a finding nobody has answered. Treatin thread, and a missing approval alike, and the response differs by cause. 2. A review is confirmed on the **current head SHA**. On a pull request into a branch other than the default, a head after Copilot's first round can carry an attested local pass instead, - `review_on_head=local` in the digest, where the whole review history is in view and no round on + `coverage=local` in the digest, where the whole review history is in view and no round on record states or appears to state partial coverage, and a pull request into the default branch, a promotion among them, always carries a Copilot round on its head. A review is matched by commit SHA rather than assumed from a green merge-state. A push makes checks go green *before* the diff --git a/scripts/README.md b/scripts/README.md index 41b9638f7..2ea5cd978 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -176,7 +176,7 @@ python3 scripts/pr_review.py reply 452 --repo ptr727/ProjectTemplate \ python3 scripts/pr_review.py attest 452 --repo ptr727/ProjectTemplate --checkout ../worktree ``` -**A Copilot round is requested at defined moments rather than on every push.** The two moments are a pull request's first round and the head of a pull request into the default branch, a promotion among them. On a pull request into any other branch, a fix push after the first round is covered by the recorded local pass the push already owes. That pass lives in the checkout's git directory, where nothing on GitHub can read it, so `attest` publishes it as a comment carrying ``, and only after confirming the checkout is the pushed head, holds no other change, and has a covering pass in `local_review.py status`. `status` then reads that head as `review_on_head=local` with `coverage=local`, where the attestation comes from an owner, member, or collaborator, stands as a line of its own outside a fence, and no round on record states or appears to state partial coverage with the whole history in view. `wait` requests nothing on such a head. Where it is attested, `wait` polls the head's checks instead. The poll runs only while the checks can still move the merge, as the `30` paragraph below lists. Once they settle, or the pull request closes, the head ends as covered. Where it is not attested, `wait` exits `49`, and `--request` asks for a round anyway. A partial on record or a history past the window requests a round instead, since no attestation can clear either, and a request already pending is polled for as before. `attest` also refuses where the checkout's merge base with the base branch is not the pull request's own. The comment carries the findings count the covering pass recorded, which `status` prints beside the reading and nothing gates on, since a local pass's findings are advisory. The rulesets review a pull request when it opens and not on each push, since a trigger on push would spend a round however the tooling chose. A default branch the payload does not name reads as a promotion, so an unreadable field costs a Copilot request rather than passing a head on a local pass alone. +**A Copilot round is requested at defined moments rather than on every push.** The two moments are a pull request's first round and the head of a pull request into the default branch, a promotion among them. On a pull request into any other branch, a fix push after the first round is covered by the recorded local pass the push already owes. That pass lives in the checkout's git directory, where nothing on GitHub can read it, so `attest` publishes it as a comment carrying ``, and only after confirming the checkout is the pushed head, holds no other change, and has a covering pass in `local_review.py status`. `status` then reads that head as `review_on_head=local` with `coverage=local`, where the attestation comes from an owner, member, or collaborator, stands as a line of its own outside a fence, and no round on record states or appears to state partial coverage with the whole history in view. `wait` requests nothing on such a head. Where it is attested, `wait` polls the head's checks instead. The poll runs only while the checks can still move the merge, as the `30` paragraph below lists. Once they settle, or the pull request closes, the head ends as covered. Where it is not attested, `wait` exits `49`, and `--request` asks for a round anyway. A partial on record or a history past the window requests a round instead, since no attestation can clear either, and a request already pending is polled for as before. `attest` also refuses where the checkout's merge base with the base branch is not the pull request's own. The comment carries the findings count the covering pass recorded, which `status` prints beside the reading and nothing gates on, since a local pass's findings are advisory. The rulesets review a pull request when it opens and not on each push, since a trigger on push would spend a round however the tooling chose. A default branch the payload does not name reads as a promotion, so an unreadable field costs a Copilot request rather than passing a head on a local pass alone. The attestation conditions above also let one cover a head whose Copilot round states no coverage, has none carried to it, and carries no table. `status` and `wait` then read `review_on_head=yes` with `coverage=local` rather than exiting `45`. `--repo` is required and carries no default. A default names one repository, and a run from anywhere else resolves its number there instead: the digest renders, every field is well-formed, and nothing in the output disagrees. Two runs read this repository's pull requests while their own was the subject, each caught by the maintainer rather than by the run. The digest leads with `repo=OWNER/NAME` for the same reason, since a number alone reads as correct in any repository. A value that is not `OWNER/NAME` is rejected by name rather than raised as an unpacking traceback, that being the near-miss a required argument still admits. diff --git a/scripts/pr_review.py b/scripts/pr_review.py index c051c32d5..28f0cd3a5 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -35,7 +35,8 @@ carry below, or where nothing states or carries any, a round covering the head names exactly the changed files in its own file table, `coverage=table` in the digest, or where no round covering the head carries a table, the newest round that does names - them under the same change-set bound, `coverage=carried:table`. + them under the same change-set bound, `coverage=carried:table`, or an attested local + pass covers the head as described below, `coverage=local`. `review_on_head` and `rounds=` in the digest name Copilot's own coverage specifically, the reviewer this script requests and waits for, never "no review of any kind covers this head": a @@ -46,7 +47,9 @@ `review_on_head=local` reads a head no Copilot round covers on a pull request into a branch other than the default, after Copilot's first round, where an attestation vouches for it and no round on record states or appears to state partial coverage, with - `coverage=local` beside it. + `coverage=local` beside it. Under the same conditions the attestation also covers a + head whose Copilot round states no coverage, has none carried to it, and carries no file + table, which reads `review_on_head=yes coverage=local`. Use `wait` when review presence is the condition, since `status` reports an absent review without treating it as a failure. 42 = a round read fewer files than the pull request changed, so part of the diff @@ -59,8 +62,8 @@ be believed. The remedy is an issue on the repository hosting this script, and the review loop does not close until the reader is fixed. Merging regardless is the maintainer's decision rather than the agent's. - 45 = no statement of changed-file coverage describes this head, and no file table - stands in for one. A round that + 45 = no statement of changed-file coverage describes this head, and no file table or + attested local pass stands in for one. A round that states none carries the newest round that states some forward, bounded on the change set, so this covers three states: nothing ever stated coverage, the round that did describes a different set of changed files, or that comparison could not be read. @@ -3153,6 +3156,8 @@ def report_verdict(pr: dict, owner: str, repo: str) -> int: state, line, _from_head = carried if state == UNSTATED and not table_reading(owner, repo, pr)[1]: state = TABLE + if state == UNSTATED and local_beside_round(pr): + state = LOCAL if state == PARTIAL: # The unread count comes from the line that decided PARTIAL, never from past rounds. # None is unreachable there, and narrowing keeps a later change from crashing the gate. @@ -3942,7 +3947,7 @@ def digest( ) if cover == UNSTATED and on_head and not table_short: cover = TABLE - local = not on_head and local_cover(pr) + local = local_cover(pr) if not on_head else cover == UNSTATED and local_beside_round(pr) if local: cover = LOCAL unknown = unrecognized_shapes(pr) @@ -4248,10 +4253,17 @@ def digest( lines.append(f" NO FILE TABLE STANDS IN: {table_short}") elif cover == LOCAL: lines.append( - " COVERAGE IS READ FROM THE LOCAL PASS: no Copilot round covers this head, which " - "follows the pull request's first round, and a comment from an owner, member, or " - "collaborator attests a recorded local strict-review pass over exactly this head's " - f"content, which recorded {attestation(pr)} finding(s)" + " COVERAGE IS READ FROM THE LOCAL PASS: this head follows the pull request's first " + "round, " + + ( + "the Copilot round covering it states no coverage, none carries to it, and no " + "round covering it carries a file table, " + if on_head + else "no Copilot round covers it, " + ) + + "and a comment from an owner, member, or collaborator attests a recorded local " + "strict-review pass over exactly this head's content, which recorded " + f"{attestation(pr)} finding(s)" ) if cover == PARTIAL: # The line prints under the marker for the reason a suppressed block does. @@ -4861,6 +4873,16 @@ def local_cover(pr: dict) -> bool: ) +def local_beside_round(pr: dict) -> bool: + """Whether a recorded local pass covers a head whose own Copilot round leaves coverage unread. + + The caller has found that no statement reaches the head and no table stands in. This holds + only where no round covering the head carries a table at all, so a table naming fewer files + than the diff changed, which is a round's own word that it skipped one, is never overruled. + """ + return table_shortfall(pr) == NO_HEAD_TABLE and local_cover(pr) + + POLL_DELAYS = (15, 20, 30, 45, 60, 120) diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index 9ce7f6e61..b09b62954 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -7414,6 +7414,62 @@ def test_an_attested_fix_push_closes_the_wait(self) -> None: self.assertIn("review_on_head=local", out) self.assertIn("coverage=local", out) + def table_less(self, first: str = OVERVIEW, attest: bool = True) -> dict: + """A first round on an earlier head, then a round on this head stating nothing.""" + rounds = [review(oid=OLD, body=first, rid="PRR_a"), review(body=OVERVIEW, rid="PRR_b")] + return self.into(payload(rounds, files=["a.md"]), attest=attest) + + def test_an_attested_head_beside_a_table_less_round_is_covered(self) -> None: + """The round on the head states nothing and carries no table, so the attestation covers it.""" + for cmd in (["status", "7"], ["wait", "7", "--timeout", "0"]): + with self.subTest(cmd=cmd[0]): + self.out.seek(0) + self.out.truncate() + self.answer(self.table_less()) + calls = self.wire_history([hist_review(7, OVERVIEW + "\n" + COVERED)]) + with mock.patch.object(pr_review.time, "sleep"): + self.assertEqual(0, self.cli(cmd)) + self.assertEqual(0, len([c for c in calls if "requestReviews" in c[0]])) + out = self.out.getvalue() + self.assertIn("review_on_head=yes", out) + self.assertIn("coverage=local", out) + self.assertIn("COVERAGE IS READ FROM THE LOCAL PASS", out) + self.assertNotIn("COVERAGE_IS_UNSTATED", out) + + def test_an_unattested_head_beside_a_table_less_round_stays_unstated(self) -> None: + self.answer(self.table_less(attest=False)) + self.assertEqual(45, self.cli(["status", "7"])) + self.assertIn("status=COVERAGE_IS_UNSTATED", self.out.getvalue()) + + def test_an_attested_head_beside_a_round_whose_table_skips_a_file_is_not_covered(self) -> None: + """A table naming fewer files than the diff is the round's own word that it skipped one.""" + table = overview_v2( + findings="**Findings:** None", + covers="", + effort="**Review effort:** Balanced", + entries=0, + verdict="### Approval recommended", + ).replace("| a.py | Narrows the reader. |", "| `a.md` | Prose about the change. |") + rounds = [review(oid=OLD, body=OVERVIEW, rid="PRR_a"), review(body=table, rid="PRR_b")] + self.answer(self.into(payload(rounds, files=["a.md", "b.md"]), attest=True)) + self.assertEqual(45, self.cli(["status", "7"])) + out = self.out.getvalue() + self.assertIn("status=COVERAGE_IS_UNSTATED", out) + self.assertNotIn("coverage=local", out) + + def test_a_partial_on_record_beside_a_table_less_round_is_not_covered(self) -> None: + """A round that read part of a diff sends the pull request to the maintainer.""" + part = OVERVIEW + "\n" + for cmd in (["status", "7"], ["wait", "7", "--timeout", "0"]): + with self.subTest(cmd=cmd[0]): + self.out.seek(0) + self.out.truncate() + self.answer(self.table_less(first=part)) + self.wire_history([hist_review(7, OVERVIEW + "\n" + COVERED)]) + with mock.patch.object(pr_review.time, "sleep"): + self.assertNotEqual(0, self.cli(cmd)) + self.assertNotIn("coverage=local", self.out.getvalue()) + def test_an_attested_head_with_a_check_running_at_the_timeout_is_pending(self) -> None: """Covered at once, the wait ended while CI ran and exit 0 read as a mergeable head.""" running = check(status="IN_PROGRESS", conclusion="", started=real_ago(60)) From 320c2e94d92367247efebdc4276ab0dc00bc4ab1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 22:23:18 -0700 Subject: [PATCH 02/24] Page pr_review.py's Changed-File List Past 100 So a Large Promotion Can State Coverage (#2768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `scripts/pr_review.py status` read at most 100 changed files, so a pull request changing more, a develop -> main promotion above all, exited 45 however complete its Copilot round's file table was. The full read now follows the `files` connection's cursor up to GitHub's documented 3000-file ceiling, so the round's own table is compared against the whole change set. - `follow()` is one cursor walk, shared by the new `page_files()` and the existing `unresolved_threads()`. - Paging runs in `full_read`, on the payload it is handed and on each re-read, so the digest and exit code always read a paged list while `wait`'s decision-only `Q_FULL` reads spend no page. `digest`'s own fallback read goes through `full_read` too. - A list reaching 3000 files stays marked cut short and is compared against nothing, as a list past the old 100-file window was. - A page read at a different head leaves the list unread under `FILES_UNREAD`. `files_shortfall` reports it, so `full_read` takes the read again on its existing schedule rather than exiting 45 on a transient cause. A page that fails to read ends the run like any other failed read. - The digest, `table_against_diff`, the UNSTATED message, and `scripts/README.md` now name the ceiling or the moved head rather than a 100-file window. Tests construct a 160-file pull request read across two pages and reach a coverage verdict (exit 0). Other cases cover a push between pages and the ceiling, each exiting 45 with its own reason, a moved head being read again, `full_read` paging a payload it was handed (the `wait` path), and a bare `gql(Q_FULL)` read not paging. Each was shown to fail with the code it covers reverted. Closes on promotion: #2758 ## Local review findings Every `introduced` finding the local passes raised is fixed on this branch. The last pass, over the current head, raised none. Declined with evidence: capping the read at the reviewer's 300-file limit rather than 3000. #2758's expected outcome names the REST ceiling of 3000, and the reviewer's limit is not a contract this script controls. Filed along the way: #2767, a carried table is still bounded by the REST compare endpoint's 300-file cap in `changed_at`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- scripts/README.md | 2 +- scripts/pr_review.py | 140 +++++++++++++++++++++++++++++----------- tests/test_pr_review.py | 111 ++++++++++++++++++++++++++++--- 3 files changed, 207 insertions(+), 46 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 2ea5cd978..4b09e091f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -196,7 +196,7 @@ The digest reports the completed head review's effective effort as `lite`, `bala `status` and `wait` both exit `42` where the round covering the head read **fewer files than the pull request changed**, or where an earlier round read fewer and the round covering the head states no coverage of its own, and `43` where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right `commit.oid`, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across **both** its rounds. This is the third instance of the shape `refusal` and `suppressed` are the first two, and the only one nothing was reading. -The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure, and where an earlier round on the same pull request states some, that round's reading carries to the head, bounded on the change set: it carries only where the pull request changes the same set of files at both commits, which is the condition measured on this fleet for the reviewer's own marker carrying, and it does not carry where either side's compare could not be read, since a bound that fell open when it could not be measured would be no bound. The delta between the two heads is reported beside the carried reading either way. Where no statement reaches the head either way, a round covering it whose own file table names exactly the changed files reads as `table`, which passes, since Copilot's Balanced review effort, the default since 2026-09-28, writes that table and almost never a statement, and a statement that reaches the head, stated on it or carried to it, wins over it, because the table names the whole changed set on partial rounds too. Where no round covering the head carries a table of its own, the newest round that does, its table naming exactly the changed files, stands in under that same change-set bound, reading as `carried:table`. A row can name a changed path three ways. It is the path, or it shortens the path with a ` ... ` gap. Or it matches the path one segment at a time, each segment holding a `*` or `?` also holding a letter or digit. A gap row fitting several changed paths names none of them. A cell holding single-backtick code spans with nothing but commas and whitespace outside them is read as one row per span. Where such a list ends in one or more notes, each span is read as a row carrying those notes. A span inside a note is not read as a path. While a row names no changed path and ends in a parenthesized note, it is read again without that note. The second format writes such a note after a path, as in `docs/a.md (cleanup)`. A note is set off by whitespace and holds no parenthesis of its own. A row naming a path only once its note comes off covers nothing, and is not counted as naming a path the diff lacks. Nothing reads the note, which could say the file went unreviewed, so a file named only on a noted row is not covered. Either table stands in only where no Copilot round on the pull request, on any commit, states or appears to state partial coverage, quoted or not, so a pull request that ever had a partial round goes to the maintainer. Past that, exit `45` names three states rather than one, and its own message says which the run reached: a pull request no round ever stated coverage on, one whose change set has moved since the round that did, and one where that comparison could not be read: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. +The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure, and where an earlier round on the same pull request states some, that round's reading carries to the head, bounded on the change set: it carries only where the pull request changes the same set of files at both commits, which is the condition measured on this fleet for the reviewer's own marker carrying, and it does not carry where either side's compare could not be read, since a bound that fell open when it could not be measured would be no bound. The delta between the two heads is reported beside the carried reading either way. Where no statement reaches the head either way, a round covering it whose own file table names exactly the changed files reads as `table`, which passes, since Copilot's Balanced review effort, the default since 2026-09-28, writes that table and almost never a statement, and a statement that reaches the head, stated on it or carried to it, wins over it, because the table names the whole changed set on partial rounds too. Where no round covering the head carries a table of its own, the newest round that does, its table naming exactly the changed files, stands in under that same change-set bound, reading as `carried:table`. The changed-file list a table is matched against is paged past the connection's 100 paths a page. It is read up to GitHub's own ceiling of 3,000 files, so a large promotion's own table is matched against its whole change set. A carried table still needs the change set compared at both commits, which the compare endpoint caps at 300 files. A list not read whole is matched against nothing, since a path missing from a short list reads exactly like one the table left out. A list at the ceiling is one. So is a list a push cut short while it was paged, which `status` and `wait` read again where a table would otherwise stand in. A row can name a changed path three ways. It is the path, or it shortens the path with a ` ... ` gap. Or it matches the path one segment at a time, each segment holding a `*` or `?` also holding a letter or digit. A gap row fitting several changed paths names none of them. A cell holding single-backtick code spans with nothing but commas and whitespace outside them is read as one row per span. Where such a list ends in one or more notes, each span is read as a row carrying those notes. A span inside a note is not read as a path. While a row names no changed path and ends in a parenthesized note, it is read again without that note. The second format writes such a note after a path, as in `docs/a.md (cleanup)`. A note is set off by whitespace and holds no parenthesis of its own. A row naming a path only once its note comes off covers nothing, and is not counted as naming a path the diff lacks. Nothing reads the note, which could say the file went unreviewed, so a file named only on a noted row is not covered. Either table stands in only where no Copilot round on the pull request, on any commit, states or appears to state partial coverage, quoted or not, so a pull request that ever had a partial round goes to the maintainer. Past that, exit `45` names three states rather than one, and its own message says which the run reached: a pull request no round ever stated coverage on, one whose change set has moved since the round that did, and one where that comparison could not be read: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. The line is matched at its **start** rather than anywhere in the body, since both spellings are structural: across those bodies every coverage statement opens its line, 272 with the reviewer's own name and 32 as the `Review details` bullet, and none sits mid-sentence. A body-wide match reports the pull request that adds this check as a partial round, which is the false positive the suppressed matcher and the refusal matcher have each had once already, and fenced blocks are dropped for the same reason, 131 of the bodies carrying one and this change putting both spellings into the diff a review of it quotes. The cost is named rather than hidden: a wording that moves the statement off the line start reads as no statement rather than as one this cannot parse. The reading was **head-scoped**, unlike a suppressed finding and like a refusal, on the reasoning that a partial round describes one commit's diff and the push that changes that diff raises a round reading the whole of the new one. The carry is what qualifies that: five of ten rounds in the second format raise no statement at all, so the push often raises a round that reads nothing back, and a partial carries to the head under the same change-set bound a full one does, which is a 42 decided by a round that is not on the head. Where one head carries two rounds through a re-request the worst of them still reports, since the one naming files it did not read is the one to answer. A case reads the vetted spellings out of the runbook and hands them to this script's own parser, so the pair stays in step in both directions. diff --git a/scripts/pr_review.py b/scripts/pr_review.py index 28f0cd3a5..b84460a8a 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -336,7 +336,7 @@ import tarfile import time import unicodedata -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from datetime import UTC, datetime from pathlib import Path @@ -769,12 +769,9 @@ def strip_fences( # A test holds this equal to the number the queries carry, since a drift between them reads clean. WINDOW = 100 -# How many of the pull request's own changed files the full query asks for. -# 100 is the connection's own ceiling, so a longer diff is truncated rather than paged. -# A truncated list is reported as one and compared against nothing. -# A path missing from a short window reads exactly like a path the reviewer left out. -# The record holds a pull request of 301 changed files, so the truncation is reachable here. FILES_WINDOW = 100 +FILES_CAP = 3000 +FILES_UNREAD = "unread" # The REST compare endpoint's own ceiling on the file list it returns, which it does not flag. COMPARE_FILE_CAP = 300 READ_RETRY_DELAYS = (2, 5) @@ -883,9 +880,15 @@ def strip_fences( repository(owner:$o,name:$r){ pullRequest(number:$n){ __HELD_FIELDS__ reviewThreads(first:100){ nodes{ id isResolved comments(first:1){ nodes{ author{login} path line body fullDatabaseId pullRequestReview{ id } } } } pageInfo{ hasNextPage } } - files(first:__FILES_WINDOW__){ pageInfo{ hasNextPage } nodes{ path } } + files(first:__FILES_WINDOW__){ pageInfo{ hasNextPage endCursor } nodes{ path } } }}} """.replace("__HELD_FIELDS__", HELD_FIELDS).replace("__FILES_WINDOW__", str(FILES_WINDOW)) +Q_FILES = """ +query($o:String!,$r:String!,$n:Int!,$after:String){ + repository(owner:$o,name:$r){ pullRequest(number:$n){ headRefOid + files(first:__FILES_WINDOW__, after:$after){ pageInfo{ hasNextPage endCursor } nodes{ path } } + }}} +""".replace("__FILES_WINDOW__", str(FILES_WINDOW)) # Substituted rather than interpolated, because GraphQL is braces from end to end. # An f-string would need every one of them doubled, which is unreadable against the schema. @@ -1038,6 +1041,55 @@ def gql(query: str, owner: str, repo: str, num: int) -> dict: return pr +def follow( + query: str, owner: str, repo: str, num: int, connection: str, after: str | None = None +) -> Iterator[dict]: + """Each page of the pull request's `connection`, from the one past `after` to the last. + + One cursor walk for every paged read, so a fix to how a page is followed lands once. + """ + while True: + extra: dict[str, str] = {"after": after} if after else {} + pr = gh_graphql(query, o=owner, r=repo, n=num, **extra)["repository"]["pullRequest"] + yield pr + page = pr[connection].get("pageInfo") or {} + if not page.get("hasNextPage"): + return + after = page["endCursor"] + + +def page_files(owner: str, repo: str, num: int, pr: dict) -> None: + """Read the rest of `pr`'s changed-file list into it, past the full read's first page. + + `FILES_WINDOW` is the connection's own ceiling on one page, so a longer list is paged. + `FILES_CAP` is GitHub's documented ceiling on the whole list, which the API need not flag, + so a list reaching it stays marked as cut short. A page read at a head other than the first + page's leaves the list unread instead, under `FILES_UNREAD`, which `files_shortfall` + reports and `full_read` reads again. A list cut short or unread is compared against + nothing, where a list marked whole but missing a path reads exactly like a path the + reviewer left out. A page that fails to read ends the run as any other failed read does, + since that failure is already loud. + """ + files = pr.get("files") + if not isinstance(files, dict): + return + page = files.get("pageInfo") + if not isinstance(page, dict) or page.get("hasNextPage") is not True: + return + nodes = files["nodes"] + for more in follow(Q_FILES, owner, repo, num, "files", page["endCursor"]): + if more["headRefOid"] != pr.get("headRefOid"): + files[FILES_UNREAD] = ( + "the head moved while the changed-file list was paged, so the list describes " + "no one commit" + ) + return + nodes.extend(more["files"]["nodes"]) + if len(nodes) >= FILES_CAP: + return + files["pageInfo"] = {"hasNextPage": False} + + def reviewer_requested(pr: dict) -> bool: """True where the reviewer sits in the pending request set. @@ -1846,11 +1898,15 @@ def files_shortfall(pr: dict) -> str: One predicate for the table reading's reason and for `files_unread`'s re-read, so the two cannot disagree about which lists were read. A list the query cut short is not a shortfall - here, since that list was read, and `table_shortfall` names it separately. + here, since that list was read, and `table_shortfall` names it separately. A list + `page_files` left unread, a page arriving at another head, is one, since reading + again can answer it. """ files = pr.get("files") if not isinstance(files, dict): return "the changed-file list is absent from the query, so the table has nothing to match" + if unread := files.get(FILES_UNREAD): + return f"{unread}, so the table cannot be matched against it" nodes = files.get("nodes") page = files.get("pageInfo") if ( @@ -1889,10 +1945,21 @@ def full_read(owner: str, repo: str, num: int, pr: dict | None = None) -> dict: """The full payload, `pr` where one was read already, read again while `files_unread` holds. The whole payload is read again rather than the one connection. The digest and the exit code - then still come from one read of the pull request. + then still come from one read of the pull request. The changed-file list is paged here, on + `pr` and on each read again, rather than on every `Q_FULL` read, since this is the payload + the digest and the exit code read the list from, and a read deciding only a wait's next step + never looks at it. `pr` is a read `page_files` has not yet seen, which every caller passes. """ - read = functools.partial(gql, Q_FULL, owner, repo, num) - return backoff(read() if pr is None else pr, read, files_unread, delays=READ_RETRY_DELAYS) + + def read() -> dict: + fresh = gql(Q_FULL, owner, repo, num) + page_files(owner, repo, num, fresh) + return fresh + + if pr is None: + return backoff(read(), read, files_unread, delays=READ_RETRY_DELAYS) + page_files(owner, repo, num, pr) + return backoff(pr, read, files_unread, delays=READ_RETRY_DELAYS) @functools.cache @@ -2662,10 +2729,12 @@ def span_list(text: str) -> list[str]: def changed_paths(pr: dict) -> tuple[list[str], bool]: - """The paths this pull request changes, and whether the query's window cut the list short. + """The paths this pull request changes, and whether that list is incomplete. - Truncation is carried rather than hidden, since a path outside a short window reads exactly - like a path the reviewer left out of its table, and the two take opposite readings. + Incomplete is a list cut short at GitHub's ceiling, and also one `page_files` left unread, + whose first page's `hasNextPage` still stands, `FILES_UNREAD` naming which of the two it is. + It is carried rather than hidden, since a path missing from the list reads exactly like a + path the reviewer left out of its table, and the two take opposite readings. """ files = pr.get("files") or {} paths = [n.get("path") or "" for n in (files.get("nodes") or [])] @@ -2722,11 +2791,16 @@ def table_against_diff(pr: dict, counts: tuple[int, int] | None) -> str: if not named: return "no round covering this head carries a file table, so none of them locates the file" changed, truncated = changed_paths(pr) - if truncated or not changed: + unread = (pr.get("files") or {}).get(FILES_UNREAD) + if truncated or not changed or unread: + why = unread or ( + f"the changed-file list reaching the {FILES_CAP} files GitHub lists" + if truncated + else "the changed-file list being absent from the query" + ) return ( f"the reviewer names {len(named)} entries in its own table and the diff could not " - f"be read back to compare them, the changed-file list being " - f"{'longer than the window this reads' if truncated else 'absent from the query'}" + f"be read back to compare them, {why}" ) left, invented, ambiguous, noted = table_match(named, {bare_path(c) for c in changed}) omitted = [p for p in changed if bare_path(p) in left] @@ -2843,8 +2917,8 @@ def table_shortfall(pr: dict, named: list[str] | None = None) -> str: Empty where the table stands in, which is the coverage reading `table_reading` gives. Otherwise the reason, which the digest prints under an unstated head, since the remedies differ: a missing or mismatched table may be answered by another round, a changed-file list - longer than the window this reads by a push bringing it back within it, and a partial on - record by nothing but the maintainer's reading. + at GitHub's own ceiling by a split, and a partial on record by nothing but the maintainer's + reading. `named` is an earlier round's table where `table_reading` matches a carried one, and the head's own table otherwise. Every other check reads the pull request either way, since a @@ -2870,9 +2944,8 @@ def table_shortfall(pr: dict, named: list[str] | None = None) -> str: changed, truncated = changed_paths(pr) if truncated: return ( - f"the pull request changes more than the {FILES_WINDOW} files this reads, so the " - f"table cannot be compared against all of them until a push brings the pull request " - f"back within that window" + f"the pull request changes {FILES_CAP} files or more, GitHub's own ceiling on the " + f"changed-file list, so the table cannot be compared against all of them" ) diff = {bare_path(p) for p in changed} if len(diff) != len(set(changed)): @@ -3214,9 +3287,10 @@ def report_verdict(pr: dict, owner: str, repo: str) -> int: "effort states no coverage without them, and where they are missing bring them onto the branch, " "push, and run wait, which requests a round on the new head. Where the digest says " "the comparison could not be read, run status again, since a failed API read is " - "one cause of that. Where the digest says the pull request changes more files than " - "this reads, the table cannot be matched at all, and splitting the pull request is " - "the remedy where it applies. " + "one cause of that, and the same holds where it says the head moved while the " + "changed-file list was paged. Where the digest names GitHub's " + "own ceiling on the changed-file list, the table cannot be matched at all, and " + "splitting the pull request is the remedy where it applies. " "A re-request on this same head is not the remedy it reads as, because it returns a " "round stating coverage or carrying a table only by chance. Past those, a develop -> " "main promotion can still satisfy coverage, in the one case pr-review-conduct's Merge " @@ -3909,7 +3983,7 @@ def digest( `stalled` remains as an ignored compatibility parameter for callers that supplied the old pickup reading. GitHub's effort-labeled lifecycle makes that reading inconclusive. """ - pr = gql(Q_FULL, owner, repo, num) if pr is None else pr + pr = full_read(owner, repo, num) if pr is None else pr now = datetime.now(UTC) if now is None else now head = pr["headRefOid"] revs = reviewer_nodes(pr, "reviews") @@ -4538,17 +4612,9 @@ def unresolved_threads(owner: str, repo: str, num: int) -> list[dict]: no-match is indistinguishable from a thread that was already answered. """ out: list[dict] = [] - after = None - while True: - extra: dict[str, str] = {"after": after} if after else {} - conn = gh_graphql(Q_THREADS, o=owner, r=repo, n=num, **extra)["repository"]["pullRequest"][ - "reviewThreads" - ] - out += open_threads(conn["nodes"]) - page = conn.get("pageInfo") or {} - if not page.get("hasNextPage"): - return out - after = page["endCursor"] + for pr in follow(Q_THREADS, owner, repo, num, "reviewThreads"): + out += open_threads(pr["reviewThreads"]["nodes"]) + return out def describe(thread: dict) -> str: diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index b09b62954..a1a66243c 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -526,8 +526,13 @@ def answer(self, *responses: dict) -> mock.MagicMock: `gh_rest` is patched in `setUp` rather than here, so a case that never calls this still makes no REST read. + + `page_files` is patched to leave each response as it stands, since a response here is + the whole read, a list marked cut short included. A case about paging patches + `gh_graphql` itself rather than calling this. """ queue = list(responses) + self.enterContext(mock.patch.object(pr_review, "page_files")) def fake(_query, _owner, _repo, _num): return queue.pop(0) if len(queue) > 1 else queue[0] @@ -5387,7 +5392,7 @@ def test_the_shortfall_names_why_no_table_stands_in(self) -> None: (["a.md"], ["a.md", "b.md"], False, "the table leaves out b.md"), (["a.md", "x.md"], ["a.md"], False, "the table names x.md, which the diff does not"), (["x.md"], ["a.md"], False, "the table leaves out a.md, and names x.md, which"), - (["a.md"], ["a.md"], True, "changes more than the 100 files this reads"), + (["a.md"], ["a.md"], True, "changes 3000 files or more, GitHub's own ceiling"), (["a.md"], [], False, "the pull request changes no files"), ): with self.subTest(reason=reason): @@ -5402,15 +5407,106 @@ def test_the_shortfall_names_why_no_table_stands_in(self) -> None: pr = payload([review(body=body)], files=files, more_files=more) self.assertIn(reason, pr_review.table_shortfall(pr)) - def test_the_unstated_message_names_splitting_for_a_list_past_the_window(self) -> None: + def test_the_unstated_message_names_splitting_for_a_list_past_the_ceiling(self) -> None: self.answer( payload([review(body=self.balanced(["a.md"]))], files=["a.md"], more_files=True) ) self.assertEqual(45, pr_review.main(["status", "7", "--repo", "o/r"])) printed = self.out.getvalue() - self.assertIn("NO FILE TABLE STANDS IN: the pull request changes more than", printed) + self.assertIn("NO FILE TABLE STANDS IN: the pull request changes 3000 files", printed) self.assertIn("splitting the pull request is the remedy", printed) + def paged( + self, pages: list[tuple[list[str], str | None]], named: list[str], heads: list[str] + ) -> list[dict]: + """Answer each full read with `pages[0]` and each later page from `Q_FILES` in turn. + + Each page is its paths and the cursor to the next, None on the last. `heads` is the + head each `Q_FILES` call reports, across every full read, so a push landing mid-list is + expressible. Every call is recorded, and a + document neither of these answers reports no Copilot history. + """ + calls: list[dict] = [] + state = {"page": 0, "reads": 0} + + def connection(index: int) -> dict: + paths, cursor = pages[index] + info = {"hasNextPage": cursor is not None, "endCursor": cursor} + return {"nodes": [{"path": x} for x in paths], "pageInfo": info} + + def fake(query: str, **variables: object) -> dict: + calls.append(variables) + if query == pr_review.Q_FULL: + state["page"] = 0 + pr = payload([review(body=self.balanced(named))]) + pr["files"] = connection(0) + return {"repository": {"pullRequest": pr}} + if query == pr_review.Q_FILES: + state["page"] += 1 + head = heads[state["reads"]] + state["reads"] += 1 + more = {"headRefOid": head, "files": connection(state["page"])} + return {"repository": {"pullRequest": more}} + return {"repository": {"pullRequests": {"nodes": []}}} + + self.enterContext(mock.patch.object(pr_review, "gh_graphql", side_effect=fake)) + return calls + + def test_a_changed_file_list_past_one_page_is_read_whole_and_matched(self) -> None: + """A promotion of 160 changed files reaches a coverage verdict rather than exit 45.""" + first = [f"d/f{i:03}.md" for i in range(100)] + rest = [f"d/f{i:03}.md" for i in range(100, 160)] + calls = self.paged([(first, "c1"), (rest, None)], first + rest, [HEAD]) + self.assertEqual(0, pr_review.main(["status", "7", "--repo", "o/r"])) + self.assertIn("names exactly the 160 changed files in", self.out.getvalue()) + self.assertIn({"o": "o", "r": "r", "n": 7, "after": "c1"}, calls) + + def test_a_list_not_read_whole_is_matched_against_nothing(self) -> None: + """A push between pages and the ceiling each leave the table unmatched.""" + first = [f"d/f{i:03}.md" for i in range(100)] + rest = [f"d/f{i:03}.md" for i in range(100, 160)] + moved = "the head moved while the changed-file list was paged" + ceiling = "the pull request changes 160 files or more, GitHub's own ceiling" + cases: list[tuple[list[tuple[list[str], str | None]], list[str], int, str]] = [ + ([(first, "c1"), (rest, None)], [OLD], 3000, moved), + ([(first, "c1"), (rest, None)], [HEAD], 160, ceiling), + ([(first, "c1"), (rest, "c2")], [HEAD], 160, ceiling), + ] + for pages, heads, cap, reason in cases: + with self.subTest(reason=reason, pages=len(pages), last=pages[-1][1]): + self.out.seek(0) + self.out.truncate() + self.enterContext(mock.patch.object(pr_review, "FILES_CAP", cap)) + self.paged(pages, first + rest, heads) + self.assertEqual(45, pr_review.main(["status", "7", "--repo", "o/r"])) + self.assertIn(f"NO FILE TABLE STANDS IN: {reason}", self.out.getvalue()) + + def test_a_read_outside_the_digest_does_not_page_the_list(self) -> None: + """A wait's own reads never look at the list, so they spend no page on it.""" + first = [f"d/f{i:03}.md" for i in range(100)] + calls = self.paged([(first, "c1"), (["d/x.md"], None)], first, [HEAD]) + pr = pr_review.gql(pr_review.Q_FULL, "o", "r", 7) + self.assertTrue(pr["files"]["pageInfo"]["hasNextPage"]) + self.assertEqual([], [c for c in calls if "after" in c]) + + def test_a_read_already_taken_is_paged_by_the_full_read(self) -> None: + """`wait` hands its own last read to the full read, which pages it rather than reading anew.""" + first = [f"d/f{i:03}.md" for i in range(100)] + calls = self.paged([(first, "c1"), (["d/x.md"], None)], first, [HEAD]) + pr = pr_review.full_read("o", "r", 7, pr_review.gql(pr_review.Q_FULL, "o", "r", 7)) + self.assertEqual(101, len(pr["files"]["nodes"])) + self.assertFalse(pr["files"]["pageInfo"]["hasNextPage"]) + self.assertEqual(1, len([c for c in calls if "after" not in c and "n" in c])) + + def test_a_list_left_unread_by_a_push_is_read_again(self) -> None: + """A push between pages is transient, so the full read is taken again and matches.""" + first = [f"d/f{i:03}.md" for i in range(100)] + rest = [f"d/f{i:03}.md" for i in range(100, 160)] + self.enterContext(mock.patch.object(pr_review, "READ_RETRY_DELAYS", (0,))) + self.paged([(first, "c1"), (rest, None)], first + rest, [OLD, HEAD]) + self.assertEqual(0, pr_review.main(["status", "7", "--repo", "o/r"])) + self.assertIn("names exactly the 160 changed files in", self.out.getvalue()) + def test_a_partial_the_coverage_reader_misses_still_keeps_the_table_out(self) -> None: """Wrapped, mid-line, unprefixed, or fenced, a partial count is still on record.""" for partial in ( @@ -5826,14 +5922,13 @@ def test_a_table_short_by_more_than_the_counts_tracks_neither(self) -> None: self.assertIn("names 1 of the 3 changed files", out) self.assertIn("names no unread file", out) - def test_a_changed_file_list_the_window_cut_short_is_not_compared(self) -> None: - """A path outside the window reads exactly like a path the reviewer left out. + def test_a_changed_file_list_the_ceiling_cut_short_is_not_compared(self) -> None: + """A path past GitHub's ceiling on the list reads exactly like a path the reviewer left out. - The record holds a pull request of 301 changed files, so this is reachable rather than - theoretical, and reading it would name every file past the window as unreviewed. + Reading a list cut short there would name every file past the ceiling as unreviewed. """ out = self.reading(summarized(["a.py"], covers=self.PART), ["a.py"], more=True) - self.assertIn("longer than the window this reads", out) + self.assertIn(f"reaching the {pr_review.FILES_CAP} files GitHub lists", out) self.assertNotIn("corroborates nothing", out) def test_a_head_carrying_no_table_at_all_says_so_rather_than_staying_silent(self) -> None: From b8b32c394924519abd084b40ba4abcf0dfb582e3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 23:07:53 -0700 Subject: [PATCH 03/24] Strip a List Item's Opening Label for Every Marker and Bold Spelling in the Prose Gate (#2771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `LABEL_COLON` in the prose gate strips a list item's opening bold label before the semicolon check reads the line. It kept its own marker set (`-`, `*`, `N.`) and read only `**` emphasis, so a bullet opened with `+` or `N)`, or a label spelled `__Label__:`, `__Label:__`, or `***Label***:`, kept its label. That label then announced a list and exempted the splice after it, so one bullet got two verdicts depending on how it was spelled. - `LIST_MARKER_TOKEN` is now the one marker set, and both `LIST_MARKER` and `LABEL_COLON` build on it. - `LABEL_COLON` accepts every strong-emphasis spelling, `**`/`***` and `__`/`___`, with the colon inside or outside the emphasis, and the closing delimiter must match the opening one. - A new test feeds 8 marker spellings by 7 label spellings and asserts one verdict for all 56. On the old regex 46 of them fail. A second test pins that an unmarked or mismatched label is not stripped. The semicolon findings across the tree's Markdown are identical before and after the change. ## Local review One `local-strict-review` pass, recorded. It raised no finding on this change. It raised one pre-existing finding: `LABEL_DASH` has the same narrower marker and emphasis set for the dash rule. That is filed as #2770 rather than widened into this change. Later passes raised findings this change introduced. Two were fixed in this push: the shared token had landed between `LABEL_COLON` and its rationale comment, and a bold-italic label with a single outer mark (`_**Label**_:`, `*__Label__*:`) was not stripped. ### Findings still open after the edit budget - **introduced**: `LABEL_COLON` does not strip a bold label whose inner italic holds the colon, `**_Label:_**` or `__*Label:*__`. The colon-outside forms `**_Label_**:` and `__*Label*__:` are stripped, so this nesting still gets two verdicts. No tree line uses it. **Fixed in 8bf2438c**, an optional inner italic mark matched on both sides. A later pass's design finding narrowed the strong delimiter to exactly two marks in 4a1df84b, since the outer mark already spells a triple run. The pass over 4a1df84b raised nothing. Closes on promotion: #2738 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved prose linting to recognize emphasized labels in bulleted and numbered lists, including varied emphasis styles and colon placement. * Expanded detection coverage for blockquoted lists and added checks to avoid treating unmarked or mismatched labels as bullets. --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 12 +++++-- tests/test_prose_lint.py | 44 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 9067ca47a..091312220 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1675,11 +1675,19 @@ def blank(m: re.Match[str]) -> str: return re.sub(r'"([^"\n]*)"', blank, s) +LIST_MARKER_TOKEN = r"(?:[-*+]|([0-9]+)[.)])" + # A bullet's `**Label**:` opens the text the same way `- **Label** -` does. # Its colon introduces the bullet rather than a list, and reading it as one excused the splice. # The colon is written inside the emphasis as often as outside it, and both spell one construct. # Matching `**Label**:` alone left `**Label:**` announcing a list it never announced. -LABEL_COLON = re.compile(r"^\s*(?:[-*]|[0-9]+\.)\s+\*\*[^*]+?(?:\*\*\s*:|:\s*\*\*)") +LABEL_COLON = re.compile( + rf"^\s*{LIST_MARKER_TOKEN}\s+(?P[*_]?)(?:" + r"(?P\*\*)(?P_?)[^*\n]+?" + r"(?:(?P=si)(?P=star)(?P=outer)\s*:|:\s*(?P=si)(?P=star)(?P=outer))" + r"|(?P__)(?P\*?)[^_\n]+?" + r"(?:(?P=ui)(?P=under)(?P=outer)\s*:|:\s*(?P=ui)(?P=under)(?P=outer)))" +) # A sentence boundary inside one line, so a list exemption is scoped to the sentence holding it. # The guards are the run-on rule's, so an initial or an abbreviation ends nothing. @@ -1707,7 +1715,7 @@ def sentences(span: str) -> list[str]: return [s for s in SENTENCE_BREAK.split(span) if s.strip()] -LIST_MARKER = re.compile(r"^\s*(?:[-*+]|([0-9]+)[.)])\s") +LIST_MARKER = re.compile(rf"^\s*{LIST_MARKER_TOKEN}\s") SETEXT_UNDERLINE = re.compile(r"^ {0,3}(?:=+|-+)\s*$") THEMATIC_BREAK = re.compile(r"^ {0,3}([-*_])(?:\s*\1){2,}\s*$") TABLE_DELIMITER = re.compile( diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index a9bcfcb49..2b4f8053e 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -675,6 +675,50 @@ def test_a_bullet_label_colon_inside_the_emphasis_is_the_same_opener(self) -> No ), ) + def test_every_marker_and_bold_spelling_opens_the_bullet_the_same_way(self) -> None: + """One bullet gets one verdict, whichever marker and strong emphasis spell its label. + + The stripper kept its own narrower marker set and read only `**`, so `+`, `1)`, `__`, + and `***` left the label announcing a list and exempted the splice after it. + """ + labels = ( + "**Inputs**:", + "**Inputs:**", + "__Inputs__:", + "__Inputs:__", + "***Inputs***:", + "***Inputs:***", + "___Inputs___:", + "___Inputs:___", + "**_Inputs_**:", + "**_Inputs:_**", + "__*Inputs*__:", + "__*Inputs:*__", + "_**Inputs**_:", + "_**Inputs:**_", + "*__Inputs__*:", + ) + for marker in ("-", "*", "+", "1.", "1)", "12.", "12)", "> -"): + for label in labels: + with self.subTest(marker=marker, label=label): + self.assertEqual( + ["semicolon"], + self.kinds( + f"{marker} {label} a, b; **Outputs**: c and d.\n", {"semicolon"} + ), + ) + + def test_an_unmarked_or_mismatched_label_is_not_stripped(self) -> None: + """Without a list marker, or with mismatched emphasis, the label still announces a list.""" + for text in ( + "**Inputs**: a, b; **Outputs**: c and d.\n", + "- **Inputs__: a, b; **Outputs**: c and d.\n", + "- __Inputs**: a, b; **Outputs**: c and d.\n", + "- _**Inputs**: a, b; **Outputs**: c and d.\n", + ): + with self.subTest(text=text.strip()): + self.assertEqual([], self.kinds(text, {"semicolon"})) + def test_a_blockquoted_bullet_label_colon_does_not_announce_a_list(self) -> None: """A callout's `>` is structure, so its bullet is judged as the same bullet unquoted.""" for text in ( From db5a46f12f62214872b7385ed5e2e57046f3fbe5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 23:34:33 -0700 Subject: [PATCH 04/24] Exempt a List Item's Label Separator from the Dash Rule for Every Marker and Bold Spelling (#2773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `LABEL_DASH` in the prose gate exempts a list item's opening `- **Label** - explanation` separator from the dash rule. It kept its own marker set (`-`, `*`, `N.`) and read only `**` emphasis. So a bullet opened with `+` or `N)`, or a label spelled `__Label__` or `***Label***`, reported the separator that the `-` and `**` spelling of the same bullet did not. - `LIST_MARKER_TOKEN` moves above `LABEL_DASH`, and `LABEL_DASH` now builds on it. - The strong-emphasis alternation `LABEL_COLON` carried moves into one shared `STRONG_LABEL_TOKEN`, and both patterns use it. A conditional group carries whether the colon sits inside the emphasis, which is the one thing `LABEL_COLON` needs that `LABEL_DASH` does not. - A new test feeds 8 marker spellings by 11 label spellings and asserts the separator is exempt and a later dash on the line still counts. On the old regex 68 of those subtests fail. A second test pins that an unmarked or mismatched label is not exempt. ## Verification - 400,000 random strings give identical `LABEL_COLON.sub("")` output before and after. The new `LABEL_DASH` matches every line the old one matched, at the same end offset. - The tree's `dash` and `semicolon` findings are identical before and after the change. - `test_prose_lint` passes in full (427 tests) from the repository root. ruff format, ruff check, mypy, the prose gate, the eol gate, and `spec/validate.py` pass. ## Local review One `local-strict-review` pass, recorded. It raised no finding. The reviewer independently re-ran the equivalence fuzz on Python 3.12, 3.13, and 3.14. Closes on promotion: #2770 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 21 ++++++------ tests/test_prose_lint.py | 41 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 091312220..ad22cb579 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1057,11 +1057,18 @@ def discover( DASH = re.compile(r"(?<=[^\s\d])\s+-\s+(?=[^\s\d])") QUOTE_PREFIX = re.compile(r"^\s*(?:>\s*)+") +LIST_MARKER_TOKEN = r"(?:[-*+]|([0-9]+)[.)])" + +STRONG_LABEL_TOKEN = ( + r"(?P[*_]?)(?:(?P\*\*)(?P_?)|__(?P\*?))" + r"(?(star)[^*\n]|[^_\n])+?(?P:\s*)?" + r"(?(star)(?P=si)\*\*|(?P=ui)__)(?P=outer)" +) + # `- **Label** - explanation` is a definition separator, structurally a colon. # Flagging it would restructure the document format rather than the prose. # The first dash on such a line is skipped, and any later one still counts. -# An ordered marker introduces the same construct, so `1. **Label** - ...` is one too. -LABEL_DASH = re.compile(r"^\s*(?:[-*]|[0-9]+\.)\s+\*\*[^*]+\*\*[.:]?\s+-\s+") +LABEL_DASH = re.compile(rf"^\s*{LIST_MARKER_TOKEN}\s+{STRONG_LABEL_TOKEN}[.:]?\s+-\s+") # The negative lookbehind keeps a word-joining character from starting a repetition: # "either/or or must-pair" is one phrase followed by a conjunction, not a doubled word. @@ -1675,19 +1682,11 @@ def blank(m: re.Match[str]) -> str: return re.sub(r'"([^"\n]*)"', blank, s) -LIST_MARKER_TOKEN = r"(?:[-*+]|([0-9]+)[.)])" - # A bullet's `**Label**:` opens the text the same way `- **Label** -` does. # Its colon introduces the bullet rather than a list, and reading it as one excused the splice. # The colon is written inside the emphasis as often as outside it, and both spell one construct. # Matching `**Label**:` alone left `**Label:**` announcing a list it never announced. -LABEL_COLON = re.compile( - rf"^\s*{LIST_MARKER_TOKEN}\s+(?P[*_]?)(?:" - r"(?P\*\*)(?P_?)[^*\n]+?" - r"(?:(?P=si)(?P=star)(?P=outer)\s*:|:\s*(?P=si)(?P=star)(?P=outer))" - r"|(?P__)(?P\*?)[^_\n]+?" - r"(?:(?P=ui)(?P=under)(?P=outer)\s*:|:\s*(?P=ui)(?P=under)(?P=outer)))" -) +LABEL_COLON = re.compile(rf"^\s*{LIST_MARKER_TOKEN}\s+{STRONG_LABEL_TOKEN}(?(inner_colon)|\s*:)") # A sentence boundary inside one line, so a list exemption is scoped to the sentence holding it. # The guards are the run-on rule's, so an initial or an abbreviation ends nothing. diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 2b4f8053e..12e59eed1 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -485,6 +485,47 @@ def test_a_later_dash_on_a_label_line_still_counts(self) -> None: ["dash"], self.kinds("- **Bug** - wrong behavior - and worse besides\n", {"dash"}) ) + def test_every_marker_and_bold_spelling_exempts_the_label_separator(self) -> None: + """One bullet gets one verdict, whichever marker and strong emphasis spell its label. + + The exemption kept its own narrower marker set and read only `**`, so `+`, `1)`, `__`, + and `***` reported the separator the `-` and `**` spelling of the same bullet did not. + """ + labels = ( + "**Bug**", + "**Bug:**", + "**Bug**:", + "__Bug__", + "__Bug:__", + "***Bug***", + "___Bug___", + "**_Bug_**", + "__*Bug*__", + "_**Bug**_", + "*__Bug__*", + ) + for marker in ("-", "*", "+", "1.", "1)", "12.", "12)", "> -"): + for label in labels: + with self.subTest(marker=marker, label=label): + self.assertEqual( + [], self.kinds(f"{marker} {label} - wrong behavior\n", {"dash"}) + ) + self.assertEqual( + ["dash"], + self.kinds(f"{marker} {label} - wrong behavior - and worse\n", {"dash"}), + ) + + def test_an_unmarked_or_mismatched_label_separator_still_counts(self) -> None: + """Without a list marker, or with mismatched emphasis, the dash is a clause break.""" + for text in ( + "**Bug** - wrong behavior\n", + "- **Bug__ - wrong behavior\n", + "- __Bug** - wrong behavior\n", + "- _**Bug** - wrong behavior\n", + ): + with self.subTest(text=text.strip()): + self.assertEqual(["dash"], self.kinds(text, {"dash"})) + def test_compound_words_and_ranges_are_left_alone(self) -> None: for text in ( "A well-named must-pair input.\n", From 10da6b3a51737729dfac5919bcf2c13ec98252e7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 00:13:32 -0700 Subject: [PATCH 05/24] Pin the Prose Gate's Override Wiring by Structure (#2776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The tests pinning the prose gate's `comment-added` override wiring matched text over a whole file, so commenting out the action step's `ALLOW_COMMENTS` env entry, or moving the workflow's `allow-comments:` input from `Check prose step` to a sibling step's `with:`, left the suite green while the override never reached the script. - A small standard-library reader, `yaml_step` and `yaml_value` in `tests/test_prose_lint.py`, reads each value under its own step by indentation, as `spec/audit.py`'s workflow readers do, so the suite stays off PyYAML. - The override's wiring is now asserted structurally at each hop: the action input's default, the step's `env.ALLOW_COMMENTS`, the prose step's `with['allow-comments']`, the label step's `id`, `PAYLOAD_LABELED`, `GH_TOKEN`, and `LABELS_PATH`, and the resolved-base step's `id` and the prose step's `with.base`. - The three copies of run-block extraction in the file now share one `step_script(path, name)` reader that slices the named step first, so a step added ahead of the one named can no longer be run in its place. - One case pins the reader itself against both mutations from the issue. ## Verification - Each mutation named in the issue, plus a flipped input default, a replaced `base:` input, and a commented-out `GH_TOKEN`, was applied to the tree and fails at least one test, then reverted. - The full `tests` suite passes (2484 tests, 6 skipped), along with ruff format and check, mypy, the prose gate diff-scoped including `sentence-length`, and `spec/validate.py`. - Three local strict review passes are recorded. The sibling sites in other test files that the second and third passes found are filed as #2775. ## Remaining local review findings - `introduced`: the `yaml_value` docstring calls its result "the plain scalar", while it returns the raw text after the key's colon with quotes kept, which the input-default assertion relies on (`"'false'"`). A caller trusting the wording gets a loud failure rather than a silent pass. Left for the review loop after the second edit round was spent, where Copilot raised it too, and fixed in a0fb90c8ee92a35e905afefbaaf9f2a34cbc3a8f. Closes on promotion: #1665 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- tests/test_prose_lint.py | 184 ++++++++++++++++++++++++++++----------- 1 file changed, 133 insertions(+), 51 deletions(-) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 12e59eed1..b8d14cc79 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -52,16 +52,7 @@ def run_prose_gate_action(root: Path, **extra: str) -> subprocess.CompletedProce The script is read out of the action rather than restated, so a case proves what the action does rather than what this module believes it does. """ - action = PROSE_GATE_ACTION.read_text(encoding="utf-8") - _, block = action.split(" run: |\n", 1) - script_lines = [] - for line in block.splitlines(): - if line.startswith(" "): - script_lines.append(line.removeprefix(" ")) - elif not line: - script_lines.append(line) - else: - break + script = step_script(PROSE_GATE_ACTION, "Check prose step") env = ( os.environ | { @@ -72,7 +63,7 @@ def run_prose_gate_action(root: Path, **extra: str) -> subprocess.CompletedProce | extra ) return subprocess.run( - [bash_or_skip(), "-c", "\n".join(script_lines)], + [bash_or_skip(), "-c", script], cwd=root, env=env, text=True, @@ -82,15 +73,17 @@ def run_prose_gate_action(root: Path, **extra: str) -> subprocess.CompletedProce ) -def workflow_step_script(name: str) -> str: - """The `run` block of the validate-task workflow step called `name`, dedented as a runner reads it.""" - workflow = VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8") - _, body = workflow.split(f" - name: {name}\n", 1) - _, block = body.split(" run: |\n", 1) +def step_script(path: Path, name: str) -> str: + """The `run` block of the step called `name` in `path`, dedented as a runner reads it.""" + step = yaml_step(path.read_text(encoding="utf-8"), name) + match = re.search(r"(?m)^( *)run: \|\n", step) + if match is None: + raise AssertionError(f"no run block in the step named {name!r}") + indent = match.group(1) + " " script_lines = [] - for line in block.splitlines(): - if line.startswith(" "): - script_lines.append(line.removeprefix(" ")) + for line in step[match.end() :].splitlines(): + if line.startswith(indent): + script_lines.append(line.removeprefix(indent)) elif not line: script_lines.append(line) else: @@ -98,6 +91,62 @@ def workflow_step_script(name: str) -> str: return "\n".join(script_lines) +def yaml_step(text: str, name: str) -> str: + """The one sequence item named `name`, its dash blanked so its keys read as a mapping. + + Read by indentation, as `spec/audit.py`'s workflow readers are, since the suite runs on the + standard library alone. The item ends at the first line at or left of its dash, so a key under + a sibling step is never this step's. + """ + lines = text.splitlines() + starts = [ + i for i, line in enumerate(lines) if re.fullmatch(rf"( *)- name: {re.escape(name)}", line) + ] + if len(starts) != 1: + raise AssertionError(f"{len(starts)} steps named {name!r}") + first = lines[starts[0]] + dash = len(first) - len(first.lstrip()) + item = [first[:dash] + " " + first[dash + 2 :]] + for line in lines[starts[0] + 1 :]: + if line.strip() and len(line) - len(line.lstrip()) <= dash: + break + item.append(line) + return "\n".join(item) + + +def yaml_value(text: str, *keys: str) -> str | None: + """The text after the last key's colon at the nested mapping path `keys`, or None where absent. + + The text is raw rather than a parsed value, so quotes and a trailing comment are kept. + + Each key is looked for only among its parent's direct children, the shallowest lines under it, + and a comment line is no key at all, so a commented-out or relocated entry reads as absent. + """ + lines = [ + line for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#") + ] + value = None + for key in keys: + if not lines: + return None + level = min(len(line) - len(line.lstrip()) for line in lines) + found = None + for i, line in enumerate(lines): + if len(line) - len(line.lstrip()) == level and line[level:].startswith(f"{key}:"): + found = i + break + if found is None: + return None + value = lines[found][level + len(key) + 1 :].strip() + children = [] + for line in lines[found + 1 :]: + if len(line) - len(line.lstrip()) <= level: + break + children.append(line) + lines = children + return value + + def run_comments_label_step(live: str | None, payload: str) -> tuple[str, str, str, list[str]]: """Run the workflow's own label-reading step against a stub gh. @@ -105,7 +154,7 @@ def run_comments_label_step(live: str | None, payload: str) -> tuple[str, str, s `live` is what the stub prints as the label names, and None makes the read fail. """ - script = workflow_step_script("Read comments label step") + script = step_script(VALIDATE_TASK_WORKFLOW, "Read comments label step") with tempfile.TemporaryDirectory() as scratch: stub = Path(scratch) / "gh" if live is None: @@ -4191,21 +4240,12 @@ def run_block(self, extra: dict[str, str]) -> list[str]: (stub_dir / "prose_lint.py").write_text( "import json, sys\nprint(json.dumps(sys.argv[1:]))\n", encoding="utf-8" ) - action = PROSE_GATE_ACTION.read_text(encoding="utf-8") - _, block = action.split(" run: |\n", 1) - lines = [] - for line in block.splitlines(): - if line.startswith(" "): - lines.append(line.removeprefix(" ")) - elif not line: - lines.append(line) - else: - break + script = step_script(PROSE_GATE_ACTION, "Check prose step") env = {k: v for k, v in os.environ.items() if not k.startswith(("ACTION_", "GITHUB_"))} env |= {"BASE": "HEAD", "PATHS": ".", "GITHUB_ACTION_PATH": str(stub_dir)} env |= extra r = subprocess.run( - [bash_or_skip(), "-c", "\n".join(lines)], + [bash_or_skip(), "-c", script], cwd=str(root), capture_output=True, text=True, @@ -5176,10 +5216,14 @@ def test_the_workflow_reads_the_label_the_fleet_declares(self) -> None: """Four surfaces name this label, and a rename that misses one silently disarms it.""" declared = {label["name"] for label in json.loads(FLEET_LABELS.read_text(encoding="utf-8"))} self.assertIn(prose_lint.COMMENT_LABEL_NAME, declared) - workflow = VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8") - self.assertIn( - f"contains(github.event.pull_request.labels.*.name, '{prose_lint.COMMENT_LABEL_NAME}')", - workflow, + step = yaml_step( + VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8"), "Read comments label step" + ) + self.assertEqual("comments-label", yaml_value(step, "id")) + self.assertEqual( + "${{ contains(github.event.pull_request.labels.*.name, " + f"'{prose_lint.COMMENT_LABEL_NAME}') }}}}", + yaml_value(step, "env", "PAYLOAD_LABELED"), ) self.assertEqual( ("0", "labeled=true"), @@ -5194,29 +5238,55 @@ def test_the_action_delivers_its_input_to_the_script(self) -> None: Two mutations survived the whole suite before this: deleting the `env:` mapping, after which the label and the promotion stand-down never reach the gate and a labeled pull request is still refused, and flipping the input's default to `'true'`, after which any - caller that omits the input has the rule stood down on every run and says nothing. + caller that omits the input has the rule stood down on every run and says nothing. Both are + read off the structure, since a substring match over the file also passed with the `env:` + entry commented out. """ action = PROSE_GATE_ACTION.read_text(encoding="utf-8") - self.assertIn("ALLOW_COMMENTS: ${{ inputs.allow-comments }}", action) - # The default has to be the off value, since a caller that passes nothing gets it. - declared = action.split("runs:", 1)[0].split(" allow-comments:", 1)[1] - self.assertIn("default: 'false'", declared) + self.assertEqual( + "${{ inputs.allow-comments }}", + yaml_value(yaml_step(action, "Check prose step"), "env", "ALLOW_COMMENTS"), + ) + self.assertEqual("'false'", yaml_value(action, "inputs", "allow-comments", "default")) def test_the_stand_down_is_the_whole_expression_the_workflow_carries(self) -> None: """Asserted whole, since a fragment of it left two disarming mutations green. Flipping the `||` to `&&` refuses every labeled pull request, and dropping the head repository term stands the rule down for a fork branch named `develop`. Both passed while - this read one fragment, and both fail against the whole expression. + this read one fragment, and both fail against the whole expression. It is read under the + prose step's own `with:`, since the same line moved to a sibling step passed a substring + match while the gate fell back to the input's default. """ - workflow = VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8") - self.assertIn( - "allow-comments: ${{ steps.comments-label.outputs.labeled == 'true' || " + step = yaml_step(VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8"), "Check prose step") + self.assertEqual( + "${{ steps.comments-label.outputs.labeled == 'true' || " "(github.event.pull_request.head.repo.full_name == github.repository && " "github.head_ref == 'develop' && " "github.base_ref == github.event.repository.default_branch) }}", - workflow, - ) + yaml_value(step, "with", "allow-comments"), + ) + + def test_the_structure_reader_sees_only_a_live_entry_under_its_own_step(self) -> None: + """The two mutations the substring matches let through, against the reader the cases use.""" + steps = ( + "steps:\n" + " - name: Gate step\n" + " env:\n" + " # FLAG: on\n" + " OTHER: off\n" + " with:\n" + " base: main\n" + " - name: Sibling step\n" + " with:\n" + " flag: on\n" + ) + gate = yaml_step(steps, "Gate step") + self.assertIsNone(yaml_value(gate, "env", "FLAG")) + self.assertEqual("off", yaml_value(gate, "env", "OTHER")) + self.assertIsNone(yaml_value(gate, "with", "flag")) + self.assertEqual("main", yaml_value(gate, "with", "base")) + self.assertEqual("on", yaml_value(yaml_step(steps, "Sibling step"), "with", "flag")) def test_the_label_is_read_live_rather_than_off_the_payload(self) -> None: """The payload is a snapshot from the event, which a label applied after it never reaches. @@ -5224,6 +5294,8 @@ def test_the_label_is_read_live_rather_than_off_the_payload(self) -> None: The live answer wins in both directions, so a payload that predates the label, or one that predates its removal, decides nothing while the read succeeds. It ignores case, as the payload's contains() does. The log line is the one the skills tell a driver to look for. + The harness sets the labels path and stubs gh, so the step's own path and token are read + off the step itself. """ for live, payload, expected in ( ("bug\ncomments\n", "false", "true"), @@ -5239,6 +5311,14 @@ def test_the_label_is_read_live_rather_than_off_the_payload(self) -> None: self.assertIn("repos/example/widget/issues/7/labels", called) self.assertIn("--paginate", called) self.assertIn(".[].name", called) + step = yaml_step( + VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8"), "Read comments label step" + ) + self.assertEqual("${{ github.token }}", yaml_value(step, "env", "GH_TOKEN")) + self.assertEqual( + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels", + yaml_value(step, "env", "LABELS_PATH"), + ) def test_a_failed_read_falls_back_to_the_payload_and_says_so(self) -> None: """A token that cannot read the labels keeps the payload's answer rather than a silent false.""" @@ -5307,7 +5387,7 @@ def resolve_base(self) -> str: output = self.root.parent / f"{self.root.name}.output" self.addCleanup(output.unlink, missing_ok=True) result = subprocess.run( - [bash_or_skip(), "-c", workflow_step_script("Resolve prose base step")], + [bash_or_skip(), "-c", step_script(VALIDATE_TASK_WORKFLOW, "Resolve prose base step")], cwd=self.root, env=os.environ | {"EVENT_BASE": self.event_base, "GITHUB_OUTPUT": str(output)}, text=True, @@ -5342,11 +5422,13 @@ def test_a_checkout_that_is_no_merge_commit_keeps_the_events_base(self) -> None: def test_the_gate_reads_the_resolved_base(self) -> None: """The resolution decides nothing unless the gate step consumes its output.""" workflow = VALIDATE_TASK_WORKFLOW.read_text(encoding="utf-8") - _, gate = workflow.split(" - name: Check prose step\n", 1) - self.assertIn( - "base: ${{ steps.prose-base.outputs.base }}", gate.split(" - name:", 1)[0] + self.assertEqual( + "${{ steps.prose-base.outputs.base }}", + yaml_value(yaml_step(workflow, "Check prose step"), "with", "base"), + ) + self.assertEqual( + "prose-base", yaml_value(yaml_step(workflow, "Resolve prose base step"), "id") ) - self.assertIn(" id: prose-base\n", workflow) class TestTheIssueRefRule(unittest.TestCase): From bae88275659461c9c283cbacd19d9ae520b4bf17 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 00:42:13 -0700 Subject: [PATCH 06/24] Share the Bounded Step Reader Across the Test Harnesses (#2778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Moves the bounded step reader into `tests/workflow_steps.py` and reads every unbounded step-script site through it, so a named step with no `run:` fails the harness instead of running the following step's script. - `step_script_text` looks for `run:` only at the step's own key level and accepts `|`, `|-`, and `|+`. - Six sites in `tests/test_release_guards.py` and `decision_script` in `tests/test_publish_plan.py` now use it. - `tests/test_workflow_steps.py` constructs a workflow whose named step has no `run:` and asserts the reader fails. Closes on promotion: #2775 Local strict review: 0 findings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- tests/test_prose_lint.py | 75 +--------------------------- tests/test_publish_plan.py | 17 +------ tests/test_release_guards.py | 60 +++-------------------- tests/test_workflow_steps.py | 48 ++++++++++++++++++ tests/workflow_steps.py | 95 ++++++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 142 deletions(-) create mode 100755 tests/test_workflow_steps.py create mode 100644 tests/workflow_steps.py diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index b8d14cc79..1a7499427 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -29,6 +29,7 @@ from unittest import mock from host_capability import bash_or_skip +from workflow_steps import step_script, yaml_step, yaml_value try: import resource # POSIX only, absent on Windows entirely rather than merely restricted @@ -73,80 +74,6 @@ def run_prose_gate_action(root: Path, **extra: str) -> subprocess.CompletedProce ) -def step_script(path: Path, name: str) -> str: - """The `run` block of the step called `name` in `path`, dedented as a runner reads it.""" - step = yaml_step(path.read_text(encoding="utf-8"), name) - match = re.search(r"(?m)^( *)run: \|\n", step) - if match is None: - raise AssertionError(f"no run block in the step named {name!r}") - indent = match.group(1) + " " - script_lines = [] - for line in step[match.end() :].splitlines(): - if line.startswith(indent): - script_lines.append(line.removeprefix(indent)) - elif not line: - script_lines.append(line) - else: - break - return "\n".join(script_lines) - - -def yaml_step(text: str, name: str) -> str: - """The one sequence item named `name`, its dash blanked so its keys read as a mapping. - - Read by indentation, as `spec/audit.py`'s workflow readers are, since the suite runs on the - standard library alone. The item ends at the first line at or left of its dash, so a key under - a sibling step is never this step's. - """ - lines = text.splitlines() - starts = [ - i for i, line in enumerate(lines) if re.fullmatch(rf"( *)- name: {re.escape(name)}", line) - ] - if len(starts) != 1: - raise AssertionError(f"{len(starts)} steps named {name!r}") - first = lines[starts[0]] - dash = len(first) - len(first.lstrip()) - item = [first[:dash] + " " + first[dash + 2 :]] - for line in lines[starts[0] + 1 :]: - if line.strip() and len(line) - len(line.lstrip()) <= dash: - break - item.append(line) - return "\n".join(item) - - -def yaml_value(text: str, *keys: str) -> str | None: - """The text after the last key's colon at the nested mapping path `keys`, or None where absent. - - The text is raw rather than a parsed value, so quotes and a trailing comment are kept. - - Each key is looked for only among its parent's direct children, the shallowest lines under it, - and a comment line is no key at all, so a commented-out or relocated entry reads as absent. - """ - lines = [ - line for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#") - ] - value = None - for key in keys: - if not lines: - return None - level = min(len(line) - len(line.lstrip()) for line in lines) - found = None - for i, line in enumerate(lines): - if len(line) - len(line.lstrip()) == level and line[level:].startswith(f"{key}:"): - found = i - break - if found is None: - return None - value = lines[found][level + len(key) + 1 :].strip() - children = [] - for line in lines[found + 1 :]: - if len(line) - len(line.lstrip()) <= level: - break - children.append(line) - lines = children - return value - - def run_comments_label_step(live: str | None, payload: str) -> tuple[str, str, str, list[str]]: """Run the workflow's own label-reading step against a stub gh. diff --git a/tests/test_publish_plan.py b/tests/test_publish_plan.py index abd60b8ce..d975594a8 100755 --- a/tests/test_publish_plan.py +++ b/tests/test_publish_plan.py @@ -10,6 +10,7 @@ from pathlib import Path from host_capability import bash_or_skip +from workflow_steps import step_script REPO = Path(__file__).resolve().parents[1] WORKFLOW = REPO / ".github" / "workflows" / "publish-plan-task.yml" @@ -17,21 +18,7 @@ def decision_script() -> str: """Extract the shell body Actions executes for the release decision.""" - lines = WORKFLOW.read_text(encoding="utf-8").splitlines() - decide = next(i for i, line in enumerate(lines) if line.strip() == "id: decide") - run = next(i for i in range(decide + 1, len(lines)) if lines[i].strip() == "run: |") - run_indent = len(lines[run]) - len(lines[run].lstrip()) - start = run + 1 - content_indent = next(len(line) - len(line.lstrip()) for line in lines[start:] if line.strip()) - if content_indent <= run_indent: - raise ValueError("the decide step has no indented run body") - body = [] - for line in lines[start:]: - indent = len(line) - len(line.lstrip()) - if line.strip() and indent <= run_indent: - break - body.append(line[content_indent:] if line else "") - return "\n".join(body) + return step_script(WORKFLOW, "Decide release plan step") class PublishPlanCase(unittest.TestCase): diff --git a/tests/test_release_guards.py b/tests/test_release_guards.py index 74c24eb3d..f344e3826 100755 --- a/tests/test_release_guards.py +++ b/tests/test_release_guards.py @@ -16,6 +16,7 @@ from typing import NamedTuple from host_capability import bash_or_skip +from workflow_steps import step_script_text REPO = Path(__file__).resolve().parents[1] @@ -271,16 +272,7 @@ def test_pypi_default_versions_from_the_semver2_core(self) -> None: marker = " - name: Compute PyPI version step\n" self.assertIn(marker, action) - body = action.split(marker, 1)[1] - opener = re.search(r"(?m)^ run: \|-?\n", body) - self.assertIsNotNone(opener, "the version step's script must be a literal block scalar") - assert opener is not None - lines: list[str] = [] - for line in body[opener.end() :].splitlines(): - if line and not line.startswith(" " * 8): - break - lines.append(line[8:]) - script = "\n".join(lines) + script = step_script_text(action, "Compute PyPI version step") with tempfile.TemporaryDirectory() as scratch: output = Path(scratch) / "output" @@ -376,15 +368,7 @@ def test_release_the_token_cannot_tag_is_superseded(self) -> None: self.assertLess( workflow.index(marker), workflow.index(" - name: Create GitHub release step\n") ) - body = workflow.split(marker, 1)[1] - opener = re.search(r"(?m)^ run: \|-?\n", body) - assert opener is not None - lines: list[str] = [] - for line in body[opener.end() :].splitlines(): - if line and not line.startswith(" " * 10): - break - lines.append(line[10:]) - script = "\n".join(lines) + script = step_script_text(workflow, "Supersede a release the token cannot tag step") plan = (REPO / ".github/workflows/publish-plan-task.yml").read_text(encoding="utf-8") allowed = set(re.findall(r'"\$ACTOR" == "([^"]+)"', plan)) @@ -561,16 +545,7 @@ def test_release_gate_refuses_a_branch_git_would_not_name(self) -> None: workflow = (REPO / ".github/workflows/build-release-task.yml").read_text(encoding="utf-8") marker = " - name: Validate branch and version consistency step\n" self.assertIn(marker, workflow) - body = workflow.split(marker, 1)[1] - opener = re.search(r"(?m)^ run: \|-?\n", body) - self.assertIsNotNone(opener, "the gate's script must be a literal block scalar") - assert opener is not None - lines: list[str] = [] - for line in body[opener.end() :].splitlines(): - if line and not line.startswith(" " * 10): - break - lines.append(line[10:]) - script = "\n".join(lines) + script = step_script_text(workflow, "Validate branch and version consistency step") def gate(branch: str, semver2: str, smoke: str = "false") -> tuple[int, str]: env = {**os.environ, "BRANCH": branch, "SEMVER2": semver2, "SMOKE": smoke} @@ -1113,9 +1088,7 @@ def run_python_tests_step( The third value is what the step wrote to GITHUB_OUTPUT followed by every stub invocation. """ workflow = (REPO / ".github/workflows/validate-task.yml").read_text(encoding="utf-8") - step = workflow.split(f" - name: {step_name}\n", 1)[1] - body = step.split(" run: |\n", 1)[1].split("\n\n", 1)[0] - script = "\n".join(line[10:] for line in body.splitlines()) + script = step_script_text(workflow, step_name) root = Path(self.enterContext(tempfile.TemporaryDirectory())) work, stubs = root / "work", root / "bin" contents = tree if isinstance(tree, dict) else dict.fromkeys(tree, "") @@ -1645,16 +1618,7 @@ def test_validator_dotnet_leg_fails_when_no_report_was_written(self) -> None: job = workflow.split("\n unit-test:\n", 1)[1].split("\n validate:\n", 1)[0] marker = " - name: Run unit tests step\n" self.assertIn(marker, job) - body = job.split(marker, 1)[1] - opener = re.search(r"(?m)^ run: \|-?\n", body) - self.assertIsNotNone(opener, "the step's script must be a literal block scalar") - assert opener is not None - lines: list[str] = [] - for line in body[opener.end() :].splitlines(): - if line and not line.startswith(" " * 10): - break - lines.append(line[10:]) - script = "\n".join(lines) + script = step_script_text(job, "Run unit tests step") writers = { "report": 'mkdir -p ./coverage && echo "" > "./coverage/0f1e2d3c.cobertura.xml"', @@ -1721,17 +1685,7 @@ def test_validator_refuses_a_python_versions_value_fromjson_would_admit(self) -> marker = " - name: Validate python-versions input step\n" self.assertIn(marker, job) - body = job.split(marker, 1)[1] - opener = re.search(r"(?m)^ run: \|-?\n", body) - self.assertIsNotNone(opener, "the guard's script must be a literal block scalar") - assert opener is not None - script = body[opener.end() :] - lines: list[str] = [] - for line in script.splitlines(): - if line and not line.startswith(" " * 10): - break - lines.append(line[10:]) - script = "\n".join(lines) + script = step_script_text(job, "Validate python-versions input step") self.assertIn("jq -e", script) self.assertIn("::error::The python-versions input must be", script) guard_script = Path(self.enterContext(tempfile.TemporaryDirectory())) / "guard.sh" diff --git a/tests/test_workflow_steps.py b/tests/test_workflow_steps.py new file mode 100755 index 000000000..e46b5292c --- /dev/null +++ b/tests/test_workflow_steps.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Prove the shared step reader stays inside the step it was asked for.""" + +from __future__ import annotations + +import unittest + +from workflow_steps import step_script_text + +WORKFLOW = """\ +jobs: + build: + steps: + - name: First step + uses: ./action + + - name: Second step + run: | + echo second +""" + + +class StepScriptCase(unittest.TestCase): + """A step with no run block of its own is an error, never the next step's script.""" + + def test_step_without_run_fails_rather_than_reading_the_next_step(self) -> None: + with self.assertRaises(AssertionError): + step_script_text(WORKFLOW, "First step") + + def test_named_step_reads_its_own_script(self) -> None: + self.assertEqual("echo second", step_script_text(WORKFLOW, "Second step").strip()) + + def test_strip_chomp_indicator_is_read(self) -> None: + text = WORKFLOW.replace("run: |", "run: |-") + self.assertEqual("echo second", step_script_text(text, "Second step").strip()) + + def test_keep_chomp_indicator_is_read(self) -> None: + text = WORKFLOW.replace("run: |", "run: |+") + self.assertEqual("echo second", step_script_text(text, "Second step").strip()) + + def test_run_key_nested_under_another_key_is_not_the_steps(self) -> None: + text = "- name: A\n with:\n run: |\n echo nested\n" + with self.assertRaises(AssertionError): + step_script_text(text, "A") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/workflow_steps.py b/tests/workflow_steps.py new file mode 100644 index 000000000..b9cfa15cb --- /dev/null +++ b/tests/workflow_steps.py @@ -0,0 +1,95 @@ +"""Read one named step out of a workflow or composite action, bounded to that step. + +The suites run on the standard library alone, so the readers work by indentation. A step is +sliced out before any key is looked for in it, so a step missing a key reads as missing rather +than as the next step's key. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +def step_script_text(text: str, name: str) -> str: + """The `run` block of the step called `name` in `text`, dedented as a runner reads it. + + The `run:` key is looked for only at the step's own key level, so a step without one fails + here rather than reading the next step's script. + """ + step = yaml_step(text, name) + key_level = len(step) - len(step.lstrip()) + match = re.search(rf"(?m)^ {{{key_level}}}run: \|[-+]?\n", step) + if match is None: + raise AssertionError(f"no run block in the step named {name!r}") + indent = " " * (key_level + 2) + script_lines = [] + for line in step[match.end() :].splitlines(): + if line.startswith(indent): + script_lines.append(line.removeprefix(indent)) + elif not line: + script_lines.append(line) + else: + break + return "\n".join(script_lines) + + +def step_script(path: Path, name: str) -> str: + """The `run` block of the step called `name` in the workflow or action at `path`.""" + return step_script_text(path.read_text(encoding="utf-8"), name) + + +def yaml_step(text: str, name: str) -> str: + """The one sequence item named `name`, its dash blanked so its keys read as a mapping. + + Read by indentation, as `spec/audit.py`'s workflow readers are, since the suite runs on the + standard library alone. The item ends at the first line at or left of its dash, so a key under + a sibling step is never this step's. + """ + lines = text.splitlines() + starts = [ + i for i, line in enumerate(lines) if re.fullmatch(rf"( *)- name: {re.escape(name)}", line) + ] + if len(starts) != 1: + raise AssertionError(f"{len(starts)} steps named {name!r}") + first = lines[starts[0]] + dash = len(first) - len(first.lstrip()) + item = [first[:dash] + " " + first[dash + 2 :]] + for line in lines[starts[0] + 1 :]: + if line.strip() and len(line) - len(line.lstrip()) <= dash: + break + item.append(line) + return "\n".join(item) + + +def yaml_value(text: str, *keys: str) -> str | None: + """The text after the last key's colon at the nested mapping path `keys`, or None where absent. + + The text is raw rather than a parsed value, so quotes and a trailing comment are kept. + + Each key is looked for only among its parent's direct children, the shallowest lines under it, + and a comment line is no key at all, so a commented-out or relocated entry reads as absent. + """ + lines = [ + line for line in text.splitlines() if line.strip() and not line.lstrip().startswith("#") + ] + value = None + for key in keys: + if not lines: + return None + level = min(len(line) - len(line.lstrip()) for line in lines) + found = None + for i, line in enumerate(lines): + if len(line) - len(line.lstrip()) == level and line[level:].startswith(f"{key}:"): + found = i + break + if found is None: + return None + value = lines[found][level + len(key) + 1 :].strip() + children = [] + for line in lines[found + 1 :]: + if len(line) - len(line.lstrip()) <= level: + break + children.append(line) + lines = children + return value From f65e3251705dfdcd9c94945d1c943a1f724600ca Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 01:23:32 -0700 Subject: [PATCH 07/24] Say What pr_review.py wait --min-rounds Counts and Name It at the Timeout (#2782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `pr_review.py wait --min-rounds N` waits for a round past the Nth, so N is the `rounds=` count a `status` read before a re-request. That counting is by design: the help already said "exceed N", and the `head_review_done` docstring and its tests state it. A caller passing one above the count waits for the round after the one it wanted, and the timeout printed a bare `status=PENDING` that reads exactly like no round at all. This takes the issue's documented arm and makes the timeout say why. - The `--min-rounds` help states N is the count already seen, with a worked example. - At the timeout, where a round landing during the wait covers the head but none past N does, the `status=PENDING` line names the round counts before and after and the position it waits for. Otherwise the bare line stays. - `scripts/README.md` states what the flag counts. - Tests: the issue's shape (three rounds on the head, a fourth landing mid-wait, N=3 returns and N=4 times out with the line), a late superseded-commit round keeping the bare line with and without an older round on the head, and a reviewer that never answers keeping the bare line. Filed along the way: #2780 (the flag name reads as a count to reach, options to remove the trap) and #2781 (the repo-wide quota arm outranks a round on the head at the timeout, pre-existing). Closes on promotion: #2741 ## Local strict review Seven passes recorded, the last raising 0 findings. The README overclaim the fifth pass listed as `introduced` is fixed in 83f16c7f, and a test gap the sixth raised in 43d85b84. The pre-existing finding that the repo-wide quota arm shadows the new line is filed as #2781. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- scripts/README.md | 2 +- scripts/pr_review.py | 18 ++++++++++-- tests/test_pr_review.py | 63 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 4b09e091f..9019f4904 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -182,7 +182,7 @@ python3 scripts/pr_review.py attest 452 --repo ptr727/ProjectTemplate --checkout **Do not infer one field's spelling from another's.** Several states print upper-case, `merge=` reproduces GitHub's own enum verbatim, and a field can arrive behind a prefix, so a pattern built by analogy with a neighboring field silently never fires. `review_on_head` is the one that catches a watcher, rendering `yes` against `NO`, so a pattern written for `YES` waits out its whole bound over a review that landed. A watcher that cannot fire reads exactly like a review that has not landed, so run the command once, read a real line, and write the pattern against that. Anchor the pattern on what is unique to the field being read, meaning that field's own name with its value, and for a pair nested in a parenthesized group the group's name ahead of it, since values repeat across fields and the groups carry the same inner names as each other. A watcher testing for a bare negative matches a line a covered review wrote, so it reports no review over a review that landed and waits out its whole bound saying so. That one matches on the wrong evidence rather than failing to match, which is worse than the casing trap above, since its answer is the digest's own inverted rather than absent. Give the watcher a branch that exits non-zero where the digest cannot be read at all, or a pattern that never fires and a command that never ran are equally silent. -`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. On an attested held head it exits `30` with `status=CHECKS_PENDING` at the timeout. That is a required check still settling, one running long included, or any check while no required one has posted. It is also no check posted on a `BLOCKED`, `BEHIND`, or `DRAFT` merge, or the merge still `UNKNOWN`. A merge reading `CLEAN`, `UNSTABLE`, or `HAS_HOOKS` ends the poll ahead of all of these. On a `BLOCKED` merge three shapes of required check end the poll at once even while another required check still runs, and the exit is then `44` unless a review verdict outranks it. A failed check ends it on the first read. A status expected and never posted, and a check queued with no runner, end it only once each is past `--check-grace`. A required check merely running long ends nothing. A round requested while that poll runs ends it with `status=PENDING`, so the next `wait` polls for that round. A quota reading outranks that line with its own `46` or `47`, and an answer outside a review with `40`. `wait`'s failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, an unknown author's open thread left uncounted, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the four mutation documents named `addComment`, `requestReviews`, `addPullRequestReviewThreadReply`, and `resolveReviewThread`, so a fifth arriving is a write nobody reviewed as one. +`wait` exits `30` when the review is still pending at the timeout, which is pending rather than failed. On an attested held head it exits `30` with `status=CHECKS_PENDING` at the timeout. That is a required check still settling, one running long included, or any check while no required one has posted. It is also no check posted on a `BLOCKED`, `BEHIND`, or `DRAFT` merge, or the merge still `UNKNOWN`. A merge reading `CLEAN`, `UNSTABLE`, or `HAS_HOOKS` ends the poll ahead of all of these. On a `BLOCKED` merge three shapes of required check end the poll at once even while another required check still runs, and the exit is then `44` unless a review verdict outranks it. A failed check ends it on the first read. A status expected and never posted, and a check queued with no runner, end it only once each is past `--check-grace`. A required check merely running long ends nothing. A round requested while that poll runs ends it with `status=PENDING`, so the next `wait` polls for that round. A quota reading outranks that line with its own `46` or `47`, and an answer outside a review with `40`. `--min-rounds N` counts what `status` prints as `rounds=`, every round the reviewer posted on the pull request. That count spans every head and includes refusals, and the wait holds out for a round past the Nth. N is therefore the `rounds=` a `status` read before a re-request. One above it waits for the round after the one the re-request was for, so the round that lands leaves the wait at `30`. Where that round lands during the wait, the `status=PENDING` line names the position it still waits for. A round already there when `wait` began leaves the bare line, since nothing then tells it apart from a correct count. `wait`'s failure mode is a wrong answer rather than a crash, so the cases feed crafted GraphQL payloads: a review attributed to the wrong login, a review counted against a stale head, an unknown author's open thread left uncounted, and a wait that returns success while nothing landed. One case reads the reviewer login out of the runbook rather than restating it, since GraphQL drops the `[bot]` suffix REST carries, and another holds the script to exactly the four mutation documents named `addComment`, `requestReviews`, `addPullRequestReviewThreadReply`, and `resolveReviewThread`, so a fifth arriving is a write nobody reviewed as one. `wait` exits `64` when the target is out of scope, the same refusal `comment` and `reply` raise, checked before either the auto-request or the backoff loop reaches GitHub. The `reply` paragraph below states what that refusal covers. diff --git a/scripts/pr_review.py b/scripts/pr_review.py index b84460a8a..0928f515e 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -5365,8 +5365,10 @@ def main(argv: list[str] | None = None) -> int: "pull request, the same count `status` prints as `rounds=`, exceed N. Pass the " "`rounds=` a prior `status` already read, after re-requesting a review on a head that " "already carries one: `reviewed_head` alone would report that earlier round as done " - "at once, never waiting for the fresh one the re-request was for (default 0, so any " - "round counts, unchanged from before this flag existed)", + "at once, never waiting for the fresh one the re-request was for. N is the count " + "already seen, not the count to reach: on a pull request reading `rounds=3`, N=3 " + "waits for the fourth round and N=4 for a fifth (default 0, so any round counts, " + "unchanged from before this flag existed)", ) # `reply` takes the finding's words rather than its id. # There is deliberately no argument an id fits in, so the caller never holds one to mistype. @@ -5486,6 +5488,7 @@ def main(argv: list[str] | None = None) -> int: start = time.monotonic() pr = gql(Q_LIVE, owner, repo, a.number) + rounds_before = len(reviewer_nodes(pr, "reviews")) done, answer, drift = liveness(pr, a.min_rounds) if done and a.ignore_quota_signal: full = gql(Q_FULL, owner, repo, a.number) @@ -5761,6 +5764,17 @@ def held_read() -> dict: "to poll this pull request's own head for the full --timeout" ) return 47 + if head_review_done(final, rounds_before): + rounds = len(reviewer_nodes(final, "reviews")) + print( + f"status=PENDING this pull request now has {rounds} rounds, up from " + f"{rounds_before} when this wait began, and one that landed covers this head, but " + f"--min-rounds {a.min_rounds} waits for round {a.min_rounds + 1} or later and none " + "past that position covers the head. N is the `rounds=` a `status` read before " + "the re-request, so one above it waits for the round after the one the re-request " + "was for" + ) + return 30 print("status=PENDING") return 30 diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index a1a66243c..4c3fe784f 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -15,6 +15,7 @@ import contextlib import inspect import io +import itertools import json import os import re @@ -6807,7 +6808,67 @@ def test_min_rounds_times_out_rather_than_crediting_the_round_it_was_told_to_ski self.answer(payload([review()], pending=True)) with mock.patch.object(pr_review.time, "sleep"): self.assertEqual(30, self.cli(["wait", "7", "--min-rounds", "1", "--timeout", "0"])) - self.assertIn("status=PENDING", self.out.getvalue()) + self.assertIn("\nstatus=PENDING\n", self.out.getvalue()) + + def test_min_rounds_names_the_baseline_when_a_round_lands_mid_wait_below_it(self) -> None: + """Three rounds already sit on the head, a `status` read of `rounds=3`, and a fourth + lands while the wait polls. N is the count already seen, so `--min-rounds 3` returns on + that fourth round. One above it, `--min-rounds 4`, waits for a fifth and reaches its + timeout, which a bare `status=PENDING` reported exactly like a round that never landed. + The line names the position it waits for instead, so a caller can tell the two apart. + Each clock read advances 100 seconds, so one poll lands the fourth round and the read + after it is past the 150-second timeout. + """ + seen = [review(rid=f"PRR_{i}", at=f"2026-08-02T10:0{i}:00Z") for i in range(3)] + fourth = review(rid="PRR_fourth", at=LATE) + for baseline, code in ((3, 0), (4, 30)): + with self.subTest(min_rounds=baseline): + self.out.seek(0) + self.out.truncate() + self.answer(payload(seen, pending=True), payload([*seen, fourth])) + clock = itertools.count(0, 100).__next__ + with ( + mock.patch.object(pr_review.time, "sleep") as slept, + mock.patch.object(pr_review.time, "monotonic", clock), + ): + argv = ["wait", "7", "--min-rounds", str(baseline), "--timeout", "150"] + self.assertEqual(code, self.cli(argv)) + slept.assert_called_once() + status = [ + line for line in self.out.getvalue().splitlines() if line.startswith("status=") + ] + if code: + self.assertEqual(1, len(status)) + self.assertIn("now has 4 rounds, up from 3", status[0]) + self.assertIn("--min-rounds 4 waits for round 5 or later", status[0]) + else: + self.assertEqual([], status) + + def test_min_rounds_keeps_the_bare_pending_line_unless_a_new_round_covers_head(self) -> None: + """The line naming the baseline says a round landing during the wait covers the head, + which holds only where one does. Here a round lands mid-wait, raising the count, but on a + superseded head, so it covers nothing. N sits above the count the wait began with, the + only case the line can fire in, so the coverage clause alone keeps it bare. That holds + whether or not an earlier round covers the head. Each clock read advances 100 seconds, + so one poll lands the round and the read after it is past the 150-second timeout. + """ + for label, before in (("uncovered", [review(oid=OLD)]), ("covered", [review()])): + with self.subTest(label): + self.out.seek(0) + self.out.truncate() + self.answer( + payload(before, pending=True), + payload([*before, review(oid=OLD, rid="PRR_two", at=LATE)]), + ) + clock = itertools.count(0, 100).__next__ + with ( + mock.patch.object(pr_review.time, "sleep") as slept, + mock.patch.object(pr_review.time, "monotonic", clock), + ): + argv = ["wait", "7", "--min-rounds", "2", "--timeout", "150"] + self.assertEqual(30, self.cli(argv)) + slept.assert_called_once() + self.assertIn("\nstatus=PENDING\n", self.out.getvalue()) def test_min_rounds_counts_a_stale_off_head_round_the_way_status_does(self) -> None: """`min_rounds` must be the same count `status` prints as `rounds=`: every one of the From adc6c0c3828974b77ce80e9728e09e85214bf2c8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 02:18:36 -0700 Subject: [PATCH 08/24] Let a Round on the Head Outrank pr_review.py wait's Repo-Wide Quota Reading (#2784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `pr_review.py wait` decided its repo-wide quota arm, exit `47` with `status=COPILOT_QUOTA_EXHAUSTED_REPO_WIDE`, from repository history read when the wait began. At the timeout that arm ranked above the final read of the pull request, so a genuine round covering the head below `--min-rounds` still printed that the head carried no Copilot review of its own. - A new `standing_quota` reads the repo-wide quota signal as this pull request's own read leaves it. It is `None` where a genuine round covers the head. Otherwise it runs `quota_signal` over the history merged with this pull request's own reviews, so a review of it on any head submitted after the refusal answers that refusal, the way one anywhere in the repository would. - `wait` calls it on the opening snapshot, which gates the auto-request skip and the poll skip, and on the final read, which gates the `47` arm. A round landing during the wait that `--min-rounds` does not credit now reads as the existing min-rounds `PENDING` line, exit `30`. A push moving the head off the reviewed commit still reaches `47`. - `history_entry` and `newest_first` are pulled out of `_copilot_history_window` so the merge spells the entry and the order the same way. - The `wait` help text says all of this, that the auto-request skips only a head covered past `--min-rounds`, and that `48` ranks under `46` and above `47`. - Tests cover the issue's constructed example on the head and on a superseded head, a round already on the head, a push off the reviewed head, a newer genuine round and a newer file-count refusal on a superseded head, and an off-head round newer than the refusal at the opening read. Each was proven by reverting the clause it targets. ## Local strict review The local passes' findings were each fixed in a later push, and the last pass over the current head found none. One was declined: a pass asked to keep skipping the request where a head's round predates a quota refusal elsewhere. The issue rules `47` out for a head carrying a genuine round, and skipping the request and poll there leaves the wait ending at once on a bare `30` that no later wait changes, so the request runs as usual. Closes on promotion: #2781 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- scripts/pr_review.py | 90 ++++++++++++++++++++++++++------------ tests/test_pr_review.py | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 27 deletions(-) diff --git a/scripts/pr_review.py b/scripts/pr_review.py index 0928f515e..2f24c03e9 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -224,16 +224,16 @@ no comment url so nothing was resolved, 63 = the resolve did not report the thread resolved, 64 = the write scope could not be established or excludes the target. wait Request a review where none is outstanding, then poll until Copilot's review lands on the - current head, then print the digest. The auto-request is skipped once a review already - covers the head, once Copilot has already answered outside a formal review, or once one - is already in the pending request set, so calling `wait` again on the same PR never - double-requests. It is also skipped under 46's and 47's quota readings below, since a - request into a reached limit spends quota and returns the same refusal. It is skipped too - on a pull request into a branch other than the default once Copilot has reviewed it at - all, since a fix push there is covered by an attested local pass: an attested head is - covered at once, so the wait polls its checks instead only while they can still move - its merge, as 30 below lists, and one with no attestation exits 49 naming the `attest` - step. + current head, then print the digest. The auto-request is skipped once a review past + --min-rounds already covers the head, once Copilot has already answered outside a + formal review, or once one is already in the pending request set, so calling `wait` + again on the same PR never double-requests. It is also skipped under 46's and 47's quota + readings below, since a request into a reached limit spends quota and returns the same + refusal. It is skipped too on a pull request into a branch other than the default once + Copilot has reviewed it at all, since a fix push there is covered by an attested local + pass: an attested head is covered at once, so the wait polls its checks instead only + while they can still move its merge, as 30 below lists, and one with no attestation exits + 49 naming the `attest` step. --request asks for a round anyway. A pull request into the default branch, a promotion among them, a pull request Copilot has not reviewed yet, and one with a partial on record or a review history past the window are requested as before. The comment also carries the @@ -293,7 +293,11 @@ poll --timeout anyway once the quota is believed to have reset. 46 is read from this pull request's own reviews and always takes priority over 47, so a genuine 0/40/41/42/43/45 on this pull request outranks 47 whenever both would otherwise apply, and so does a 50, and - on a held head covered by an attested local pass a 30 or a 44. + on a held head covered by an attested local pass a 30 or a 44. A genuine round the final + read shows on the head rules 47 out, and so does any review of this pull request, on any + head, submitted after the refusal and not itself a quota refusal, since it answers that + refusal as one anywhere in the repository would. A genuine round already on the head when + the wait begins leaves the request and the poll to run as usual. A pending request remains pending until a review, an answer, or the timeout. GitHub's effort-labeled review lifecycle does not always emit `copilot_work_started`, so that event is not evidence that distinguishes queued work from abandoned work. @@ -302,9 +306,9 @@ requesting account's Copilot allowance was exhausted, where no refusal was posted to read and clearing the set and requesting again changed nothing. It is decided one poll interval after the request, and the rest of the poll is skipped, since no - request exists to answer. It cannot meet 46 or 47, since neither sends a request, - and ranks under 0/40/41/42/43/44/45/50. `status` cannot report it, since a request that - recorded nothing leaves nothing for a later read to find. + request exists to answer. It ranks under 0/40/41/42/43/44/45/46/50 and above 47. + `status` cannot report it, since a request that recorded nothing leaves nothing for a + later read to find. 49 = no Copilot round covers this head and none was requested, the pull request merging into a branch other than the default after Copilot's first round, and no attestation vouches for the head. Run the local strict review, record it, push, and run `attest`, or @@ -1165,14 +1169,25 @@ def _copilot_history_window(owner: str, repo: str, prs: int) -> list[tuple[int, for review in (node.get("reviews") or {}).get("nodes") or []: author = review.get("author") or {} if author.get("__typename") == "Bot" and author.get("login") == REVIEWER: - entries.append( - (number, {**review, "_at": review.get("submittedAt") or "", "_kind": "review"}) - ) + entries.append(history_entry(number, review, "review")) for comment in (node.get("comments") or {}).get("nodes") or []: if (comment.get("author") or {}).get("login") == REVIEWER: - entries.append( - (number, {**comment, "_at": comment.get("createdAt") or "", "_kind": "comment"}) - ) + entries.append(history_entry(number, comment, "comment")) + return newest_first(entries) + + +def history_entry(number: int, node: dict, kind: str) -> tuple[int, dict]: + """One reviewer review or comment in the shape `copilot_history` returns, stamped and tagged. + + Shared with `standing_quota`, which merges this pull request's own reviews into the history + `wait` read, so the two can never spell the entry differently. + """ + stamp = node.get("submittedAt" if kind == "review" else "createdAt") or "" + return number, {**node, "_at": stamp, "_kind": kind} + + +def newest_first(entries: list[tuple[int, dict]]) -> list[tuple[int, dict]]: + """History entries ordered newest first, the order `quota_signal` reads its first entry in.""" return sorted(entries, key=lambda pair: pair[1]["_at"], reverse=True) @@ -1213,6 +1228,24 @@ def quota_signal(history: list[tuple[int, dict]]) -> tuple[int, dict] | None: return newest if newest and quota_refusal(newest[1]) else None +def standing_quota( + history: list[tuple[int, dict]], pr: dict, number: int +) -> tuple[int, dict] | None: + """`quota_signal` over `history` as this pull request's own read leaves it, or None. + + None where a genuine round covers the head, since the reading is about a head carrying no + Copilot activity of its own. This pull request's reviews are merged in rather than trusted to + sit in `history`, which reads only the most recently updated pull requests, so a review of it, + on any head, submitted after the refusal answers that refusal as one anywhere else would. `wait` + reads this on its opening snapshot and on its final read, so both apply one rule, though a + push between them can change what the rule finds. + """ + if reviewed_head(pr): + return None + own = [history_entry(number, n, "review") for n in reviewer_nodes(pr, "reviews")] + return quota_signal(newest_first([*history, *own])) + + def rate_limited_by(pr: dict, login: str) -> str | None: """The service name a rate-limit marker gives, where this login's newest comment or review on this pull request carries one. `None` otherwise. @@ -5499,12 +5532,15 @@ def main(argv: list[str] | None = None) -> int: # An already-pending request drawing no answer at all is exactly the shape a repo-wide quota exhaustion leaves, measured over the six consecutive pull requests that followed a refusal and carried no Copilot activity at all. # The bot id for a fresh request comes from this same traversal, so a caller needing either pays for one call rather than two. history = [] if done or answer or drift else copilot_history(owner, repo) + snapshot = None if done or drift else gql(Q_FULL, owner, repo, a.number) # `--ignore-quota-signal` only changes whether the signal below is acted on. # It does not change whether the history is read, since the auto-request line still needs it for the bot id regardless. # Read from the same, unfiltered history rather than one that drops this pull request's own entries: a genuine review on an earlier head of this same pull request, superseded since by a push, is real evidence about the account and not a self-reference to discard. # A refusal on this pull request's own current head still never reaches this signal, since it is caught directly and at higher priority first. signal = None if a.ignore_quota_signal else quota_signal(history) - snapshot = None if done or drift else gql(Q_FULL, owner, repo, a.number) + quota_stop = ( + None if not signal or snapshot is None else standing_quota(history, snapshot, a.number) + ) stopped = ( None if a.ignore_quota_signal or answer or snapshot is None else stopping_refusal(snapshot) ) @@ -5517,14 +5553,14 @@ def main(argv: list[str] | None = None) -> int: ) # Request before the first poll, not just at the call site: a caller expects `wait` to make a review happen, not merely to watch for one. # Two prior gaps this closed, a push superseding an already-answered request and an auto-seed that never fired, both left nothing outstanding for the loop below to ever see land. - # Skipped once a review already covers the head, once Copilot has already answered outside a formal review, or once something is already in the request set, so a second `wait` on the same PR never double-requests. + # Skipped once a review past --min-rounds already covers the head, once Copilot has already answered outside a formal review, or once something is already in the request set, so a second `wait` on the same PR never double-requests. recorded: bool | None = None final: dict | None = None if ( not done and not answer and not drift - and not signal + and not quota_stop and not stopped and not held and not reviewer_requested(pr) @@ -5587,11 +5623,11 @@ def held_read() -> dict: "nothing and stops here. Pass --ignore-quota-signal to request and poll anyway, " "once the limit is believed to have reset." ) - elif signal and not reviewer_requested(pr): + elif quota_stop and not reviewer_requested(pr): # The poll below is skipped rather than shortened, because there is nothing partial about this signal. # The reviewer's own most recent word anywhere in the repository is the account quota, and nothing has answered it since. # Polling this pull request's own silence for up to 45 minutes would only relearn that same account state a call late. - number, hist_refusal = signal + number, hist_refusal = quota_stop print( f"note: the reviewer's own most recent activity anywhere in this repository, on " f"pull request #{number} at {hist_refusal.get('_at') or 'an unknown time'}, is a " @@ -5752,8 +5788,8 @@ def held_read() -> dict: ) return 48 # Lowest priority of the terminal readings, since it is inferred from elsewhere in the repository rather than read on this pull request directly. - if signal: - number, hist_refusal = signal + if signal and (standing := standing_quota(history, final, a.number)): + number, hist_refusal = standing print( "status=COPILOT_QUOTA_EXHAUSTED_REPO_WIDE this pull request's head carries no " f"Copilot review or comment of its own, and the reviewer's own most recent activity " diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index 4c3fe784f..4e24bc3da 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -7910,6 +7910,101 @@ def test_a_quota_reading_outranks_pending_after_a_mid_poll_request(self) -> None self.assertEqual(47, self.cli(["wait", "7", "--timeout", "1"])) self.assertNotIn("status=PENDING", self.out.getvalue()) + def test_a_round_on_the_head_outranks_the_repo_wide_quota_reading(self) -> None: + """The 47 line says the head carries no Copilot review of its own, so it cannot fire + where the final read shows one. Three rounds sit on the pull request with a re-request + pending, the reviewer's newest word elsewhere is a quota refusal, and a fourth genuine + round lands on the head mid-wait at a position `--min-rounds 4` does not credit. The + three sit on the head in the issue's example, and on a superseded head in the case only + the final read can catch. Each clock read advances 100 seconds, so one poll lands it and + the read after it is past the 150-second timeout. The opening shape is answered twice, + since the wait's liveness read and its snapshot each take one. The fourth round is stamped + before the refusal, so that the head alone is what rules 47 out. + """ + for label, oid in (("on head", HEAD), ("superseded", OLD)): + with self.subTest(label): + self.out.seek(0) + self.out.truncate() + seen = [ + review(oid=oid, rid=f"PRR_{i}", at=f"2026-08-02T10:0{i}:00Z") for i in range(3) + ] + fourth = review(rid="PRR_fourth", at="2026-08-02T10:20:00Z") + start = payload(seen, pending=True) + self.answer(start, start, payload([*seen, fourth])) + self.wire_history([hist_review(962, QUOTA_REFUSED, at="2026-08-02T10:30:00Z")]) + clock = itertools.count(0, 100).__next__ + with ( + mock.patch.object(pr_review.time, "sleep"), + mock.patch.object(pr_review.time, "monotonic", clock), + ): + argv = ["wait", "7", "--min-rounds", "4", "--timeout", "150"] + self.assertEqual(30, self.cli(argv)) + out = self.out.getvalue() + self.assertNotIn("COPILOT_QUOTA_EXHAUSTED_REPO_WIDE", out) + self.assertIn("now has 4 rounds, up from 3", out) + + def test_a_round_already_on_the_head_still_requests_and_polls(self) -> None: + """The reading assumes a head with no Copilot activity of its own, so a genuine round + there when the wait begins must not skip the request and the poll on it either. Skipped, + the wait ended at once on a bare pending line that no later wait would ever change. + """ + self.answer(payload([review(at="2026-08-02T10:00:00Z")])) + self.wire_history([hist_review(962, QUOTA_REFUSED)]) + with mock.patch.object(pr_review.time, "sleep"): + self.assertEqual(30, self.cli(["wait", "7", "--min-rounds", "1", "--timeout", "0"])) + out = self.out.getvalue() + self.assertNotIn("COPILOT_QUOTA_EXHAUSTED_REPO_WIDE", out) + self.assertNotIn("note: the reviewer's own most recent activity", out) + self.assertIn("auto-request:", out) + + def test_a_push_off_the_reviewed_head_keeps_the_repo_wide_quota_reading(self) -> None: + """A round on the head when the wait begins lets the request and poll run, but the + reading itself stands, since a push during the wait can leave the final head with no + Copilot activity of its own, which is exactly what 47 reports. The opening shape is + answered twice, since the wait's liveness read and its snapshot each take one. + """ + start = payload([review(at="2026-08-02T10:00:00Z")], pending=True) + moved = payload([review(oid=OLD, at="2026-08-02T10:00:00Z")], pending=True) + self.answer(start, start, moved) + self.wire_history([hist_review(962, QUOTA_REFUSED)]) + with mock.patch.object(pr_review.time, "sleep"): + self.assertEqual(47, self.cli(["wait", "7", "--min-rounds", "1", "--timeout", "0"])) + self.assertIn("status=COPILOT_QUOTA_EXHAUSTED_REPO_WIDE", self.out.getvalue()) + + def test_a_round_newer_than_the_refusal_answers_the_repo_wide_quota_reading(self) -> None: + """A review on this pull request newer than the refusal elsewhere answers it, the way + `quota_signal` reads one anywhere, even where a push left that review off the head. That + holds for a refusal other than the quota's too, since the reviewer ran to post it. + """ + for label, body in (("genuine", None), ("file-count refusal", REFUSED)): + with self.subTest(label): + self.out.seek(0) + self.out.truncate() + start = payload([review(at="2026-08-02T10:00:00Z")], pending=True) + newer = review(oid=OLD, rid="PRR_new", at=LATE) + if body is not None: + newer = review(oid=OLD, rid="PRR_new", at=LATE, body=body) + moved = payload([review(oid=OLD, at="2026-08-02T10:00:00Z"), newer]) + self.answer(start, start, moved) + self.wire_history([hist_review(962, QUOTA_REFUSED, at="2026-08-02T10:30:00Z")]) + argv = ["wait", "7", "--min-rounds", "1", "--timeout", "0"] + with mock.patch.object(pr_review.time, "sleep"): + self.assertEqual(30, self.cli(argv)) + self.assertNotIn("COPILOT_QUOTA_EXHAUSTED_REPO_WIDE", self.out.getvalue()) + + def test_an_off_head_round_newer_than_the_refusal_opens_no_quota_stop(self) -> None: + """The opening read answers the refusal the way the final one does, from this pull + request's own reviews, since the history window can leave them out. Read only from the + history, the wait skipped the request and the poll, then ended at once on a bare 30. + """ + self.answer(payload([review(oid=OLD, at=LATE)])) + self.wire_history([hist_review(962, QUOTA_REFUSED, at="2026-08-02T10:30:00Z")]) + with mock.patch.object(pr_review.time, "sleep"): + self.assertEqual(30, self.cli(["wait", "7", "--timeout", "0"])) + out = self.out.getvalue() + self.assertNotIn("note: the reviewer's own most recent activity", out) + self.assertIn("auto-request:", out) + def test_an_unrecognized_shape_ends_the_held_poll_at_once(self) -> None: """The 43 outranks every check reading, so polling the checks cannot change it.""" running = check(status="IN_PROGRESS", conclusion="", started=real_ago(60)) From 74189b1aabdeb452aa3bd3796584bca3ff6693a2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 03:35:41 -0700 Subject: [PATCH 09/24] Read a Carried Change Set Past the Compare Endpoint's 300-File Ceiling (#2790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary On a pull request that changes 300 files or more, `scripts/pr_review.py` refused every carry, both for a coverage statement and for a file table. The compare endpoint caps its file list at 300, so `changed_at` returned None at both commits. `status` then exited 45 and told the reader to re-run, which could never help. - I checked whether paging gets past the cap, using read-only GETs on a 308-file comparison. Page 1 returned 300 files and page 2 returned none, whatever `per_page` was set to. The compare's file list does not page. - A tree diff does get past the cap. It reads the recursive tree of the commit and of its merge base, and compares them by path, mode and object id. On that same comparison it named exactly the 326 paths that `git diff --no-renames --name-only` names. - When both compares are at the cap, `changed_at` now returns `Capped(merge_base)`, with the merge base taken from the compare it already read. `carry_holds` then reads both commits' change sets through `tree_changed_at`. Both sides are always read the same way, because the compare lists a rename once while the tree diff lists both paths. - When only one side is at the cap, the carry is refused as a different set. The other side's list is complete and has fewer than 300 entries, so the two sets cannot be equal. - A tree that GitHub truncates (at about 100,000 entries) reads as unread. No fleet repository comes close to that size. - `scripts/README.md` now describes the tree-based read. Tests: the constructed case from the issue now passes. A 400-file promotion whose first round has a table, and whose later round has no table on an unchanged change set, carries the table. Separate cases cover a coverage statement carried past the cap, a change set that moved, the cap on either side alone, a failed or truncated tree read, a capped compare that names no merge base, and a rename. With the tree reading disabled, 4 of the new cases fail. The local strict review took two passes. The first raised 8 findings, and I fixed 7 of them. The remaining one is filed as #2789: `spec/audit.py` reads trees the same way, and it is a file this change does not touch. The second pass raised nothing. Closes on promotion: #2767 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Carry comparisons now handle changes that exceed GitHub’s 300-file comparison limit by checking the affected file sets directly when both comparisons are capped. * Comparisons remain undecided when tree data is missing, unreadable, truncated, or only one comparison reaches the limit. * **Documentation** * Clarified how carry comparisons work when both changes reach the file limit. --------- Co-authored-by: Claude Opus 5.5 --- scripts/README.md | 2 +- scripts/pr_review.py | 130 ++++++++++++++++++++++++++++++------ tests/test_pr_review.py | 142 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 246 insertions(+), 28 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 9019f4904..e552bff8e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -196,7 +196,7 @@ The digest reports the completed head review's effective effort as `lite`, `bala `status` and `wait` both exit `42` where the round covering the head read **fewer files than the pull request changed**, or where an earlier round read fewer and the round covering the head states no coverage of its own, and `43` where it states its coverage in a wording this script does not read. Coverage of the head was the only coverage anything checked, and coverage of the diff is a second reading stated in a line nothing parsed: a partial round carries the right `commit.oid`, raises no threads, and reports "generated no comments", so it is the clean pass byte for byte in everything read. Over 332 Copilot review bodies on this repository, five rounds across three pull requests reported reading fewer files than were changed and all three merged, one of them leaving a file of three unread across **both** its rounds. This is the third instance of the shape `refusal` and `suppressed` are the first two, and the only one nothing was reading. -The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure, and where an earlier round on the same pull request states some, that round's reading carries to the head, bounded on the change set: it carries only where the pull request changes the same set of files at both commits, which is the condition measured on this fleet for the reviewer's own marker carrying, and it does not carry where either side's compare could not be read, since a bound that fell open when it could not be measured would be no bound. The delta between the two heads is reported beside the carried reading either way. Where no statement reaches the head either way, a round covering it whose own file table names exactly the changed files reads as `table`, which passes, since Copilot's Balanced review effort, the default since 2026-09-28, writes that table and almost never a statement, and a statement that reaches the head, stated on it or carried to it, wins over it, because the table names the whole changed set on partial rounds too. Where no round covering the head carries a table of its own, the newest round that does, its table naming exactly the changed files, stands in under that same change-set bound, reading as `carried:table`. The changed-file list a table is matched against is paged past the connection's 100 paths a page. It is read up to GitHub's own ceiling of 3,000 files, so a large promotion's own table is matched against its whole change set. A carried table still needs the change set compared at both commits, which the compare endpoint caps at 300 files. A list not read whole is matched against nothing, since a path missing from a short list reads exactly like one the table left out. A list at the ceiling is one. So is a list a push cut short while it was paged, which `status` and `wait` read again where a table would otherwise stand in. A row can name a changed path three ways. It is the path, or it shortens the path with a ` ... ` gap. Or it matches the path one segment at a time, each segment holding a `*` or `?` also holding a letter or digit. A gap row fitting several changed paths names none of them. A cell holding single-backtick code spans with nothing but commas and whitespace outside them is read as one row per span. Where such a list ends in one or more notes, each span is read as a row carrying those notes. A span inside a note is not read as a path. While a row names no changed path and ends in a parenthesized note, it is read again without that note. The second format writes such a note after a path, as in `docs/a.md (cleanup)`. A note is set off by whitespace and holds no parenthesis of its own. A row naming a path only once its note comes off covers nothing, and is not counted as naming a path the diff lacks. Nothing reads the note, which could say the file went unreviewed, so a file named only on a noted row is not covered. Either table stands in only where no Copilot round on the pull request, on any commit, states or appears to state partial coverage, quoted or not, so a pull request that ever had a partial round goes to the maintainer. Past that, exit `45` names three states rather than one, and its own message says which the run reached: a pull request no round ever stated coverage on, one whose change set has moved since the round that did, and one where that comparison could not be read: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. +The reading fails closed, so a coverage-shaped line that parses to no counts is a failure whose remedy is stated as fixing this script rather than reading past it, which is what keeps the vetted spellings honest as the wording drifts, as it has once for each of the other two patterns. The tail of the sentence is deliberately outside the unit: it says how many comments the round raised, which is not coverage, and reading it would fail every merge over a sentence ending. There are two exemptions, and the first is the one that decides the design. **A body stating no coverage at all reads as `unstated`**, never as a pass and never as a failure, and where an earlier round on the same pull request states some, that round's reading carries to the head, bounded on the change set: it carries only where the pull request changes the same set of files at both commits, which is the condition measured on this fleet for the reviewer's own marker carrying, and it does not carry where either side's compare could not be read, since a bound that fell open when it could not be measured would be no bound. The delta between the two heads is reported beside the carried reading either way. Where no statement reaches the head either way, a round covering it whose own file table names exactly the changed files reads as `table`, which passes, since Copilot's Balanced review effort, the default since 2026-09-28, writes that table and almost never a statement, and a statement that reaches the head, stated on it or carried to it, wins over it, because the table names the whole changed set on partial rounds too. Where no round covering the head carries a table of its own, the newest round that does, its table naming exactly the changed files, stands in under that same change-set bound, reading as `carried:table`. The changed-file list a table is matched against is paged past the connection's 100 paths a page. It is read up to GitHub's own ceiling of 3,000 files, so a large promotion's own table is matched against its whole change set. A carried table still needs the change set compared at both commits. The compare endpoint caps its file list at 300 on every page. Where both commits reach that cap, each change set is read from the trees either side instead. A list not read whole is matched against nothing, since a path missing from a short list reads exactly like one the table left out. A list at the ceiling is one. So is a list a push cut short while it was paged, which `status` and `wait` read again where a table would otherwise stand in. A row can name a changed path three ways. It is the path, or it shortens the path with a ` ... ` gap. Or it matches the path one segment at a time, each segment holding a `*` or `?` also holding a letter or digit. A gap row fitting several changed paths names none of them. A cell holding single-backtick code spans with nothing but commas and whitespace outside them is read as one row per span. Where such a list ends in one or more notes, each span is read as a row carrying those notes. A span inside a note is not read as a path. While a row names no changed path and ends in a parenthesized note, it is read again without that note. The second format writes such a note after a path, as in `docs/a.md (cleanup)`. A note is set off by whitespace and holds no parenthesis of its own. A row naming a path only once its note comes off covers nothing, and is not counted as naming a path the diff lacks. Nothing reads the note, which could say the file went unreviewed, so a file named only on a noted row is not covered. Either table stands in only where no Copilot round on the pull request, on any commit, states or appears to state partial coverage, quoted or not, so a pull request that ever had a partial round goes to the maintainer. Past that, exit `45` names three states rather than one, and its own message says which the run reached: a pull request no round ever stated coverage on, one whose change set has moved since the round that did, and one where that comparison could not be read: 28 of those 332 bodies are an overview and a change list and nothing more, that shape is current rather than historical and interleaves with the counted one throughout, and one pull request carries both across its two rounds, so failing on it would cry wolf on about one review in twelve and a guard an agent learns to work around is worse than none. **A refusal is exempt** because it states no coverage by design and is already classified, and reading it as a round would grow a spurious second failure on top of the one naming its remedy. The line is matched at its **start** rather than anywhere in the body, since both spellings are structural: across those bodies every coverage statement opens its line, 272 with the reviewer's own name and 32 as the `Review details` bullet, and none sits mid-sentence. A body-wide match reports the pull request that adds this check as a partial round, which is the false positive the suppressed matcher and the refusal matcher have each had once already, and fenced blocks are dropped for the same reason, 131 of the bodies carrying one and this change putting both spellings into the diff a review of it quotes. The cost is named rather than hidden: a wording that moves the statement off the line start reads as no statement rather than as one this cannot parse. The reading was **head-scoped**, unlike a suppressed finding and like a refusal, on the reasoning that a partial round describes one commit's diff and the push that changes that diff raises a round reading the whole of the new one. The carry is what qualifies that: five of ten rounds in the second format raise no statement at all, so the push often raises a round that reads nothing back, and a partial carries to the head under the same change-set bound a full one does, which is a 42 decided by a round that is not on the head. Where one head carries two rounds through a re-request the worst of them still reports, since the one naming files it did not read is the one to answer. A case reads the vetted spellings out of the runbook and hands them to this script's own parser, so the pair stays in step in both directions. diff --git a/scripts/pr_review.py b/scripts/pr_review.py index 2f24c03e9..bb9ef318d 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -343,6 +343,7 @@ from collections.abc import Callable, Iterable, Iterator from datetime import UTC, datetime from pathlib import Path +from typing import NamedTuple # The directory this script sits in, and the anchor every scope answer is read from. # One value serves the owner probe and the refusal naming it, so the two cannot disagree. @@ -1995,9 +1996,19 @@ def read() -> dict: return backoff(pr, read, files_unread, delays=READ_RETRY_DELAYS) +class Capped(NamedTuple): + """A compare whose file list reached `COMPARE_FILE_CAP`, with the merge base it named. + + The merge base is what `tree_changed_at` reads past the cap from, carried here so that + reading costs no second compare. + """ + + merge_base: str + + @functools.cache -def changed_at(owner: str, repo: str, base: str, commit: str) -> frozenset[str] | None: - """The paths a pull request off `base` changes at `commit`, or None where that was unreadable. +def changed_at(owner: str, repo: str, base: str, commit: str) -> frozenset[str] | Capped | None: + """The paths a pull request off `base` changes at `commit`, or why that could not be had. Read as a three-dot compare, which is the same merge-base reading the pull request's own file list is, so the set at an earlier commit and the set at the head are two readings of one @@ -2008,10 +2019,11 @@ def changed_at(owner: str, repo: str, base: str, commit: str) -> frozenset[str] None rather than an empty set wherever the read did not happen, since a pull request that changes nothing and a compare that could not be fetched take opposite decisions, and the caller fails closed on the second. The API caps its file list at 300, and the response carries - no flag saying it did, so a set at the cap is returned as unreadable rather than as a set that - is probably short. A name the response leaves null would coerce to the literal path `None` - and compare equal to another round's null, so an entry that is not a non-empty string makes - the whole reading unreadable rather than a set with a fabricated member in it. + no flag saying it did, so a set at the cap is returned as `Capped` rather than as a set that + is probably short, and `tree_changed_at` reads past it. A name the response leaves null + would coerce to the literal path `None` and compare equal to another round's null, so an + entry that is not a non-empty string makes the whole reading unreadable rather than a set + with a fabricated member in it. A read that did not happen is taken again on the `READ_RETRY_DELAYS` schedule before it is returned as None. One run read `unstated` on a head where the runs either side of it carried a @@ -2039,7 +2051,32 @@ def changed_at(owner: str, repo: str, base: str, commit: str) -> frozenset[str] if not base or not commit or any(UNSAFE_REF.search(r) or DOT_SEGMENT.search(r) for r in refs): return None - def listed(proc: subprocess.CompletedProcess) -> object: + compared = reread_json( + f"repos/{owner}/{repo}/compare/{base}...{commit}", + r'if (.files | type) == "array" then ({files: [.files[].filename], ' + r'merge_base: (.merge_base_commit.sha // "")} | @json) else empty end', + ) + if not isinstance(compared, dict): + return None + names = compared.get("files") + if not isinstance(names, list): + return None + if not all(isinstance(n, str) and n for n in names): + return None + if len(names) >= COMPARE_FILE_CAP: + merge_base = compared.get("merge_base") + return Capped(merge_base) if isinstance(merge_base, str) and merge_base else None + return frozenset(names) + + +def reread_json(path: str, jq: str) -> object: + """One REST read whose filter emits JSON, parsed, re-read on the `READ_RETRY_DELAYS` schedule. + + None wherever nothing parseable came back, an absence GitHub answered included, so the caller + has one value to refuse on. + """ + + def parsed(proc: subprocess.CompletedProcess) -> object: if proc.returncode != 0: return None try: @@ -2047,25 +2084,64 @@ def listed(proc: subprocess.CompletedProcess) -> object: except json.JSONDecodeError: return None - compare = functools.partial( - gh_rest, - f"repos/{owner}/{repo}/compare/{base}...{commit}", - r'if (.files | type) == "array" then ([.files[].filename] | @json) else empty end', - ) + read = functools.partial(gh_rest, path, jq) proc = backoff( - compare(), - compare, - lambda p: not answered_absent(p) and listed(p) is None, + read(), + read, + lambda p: not answered_absent(p) and parsed(p) is None, delays=READ_RETRY_DELAYS, ) - names = listed(proc) - if names is None: + return parsed(proc) + + +def tree_at(owner: str, repo: str, commit: str) -> dict[str, tuple[str, str]] | None: + """Every path in `commit`'s tree with its mode and object id, or None where that was unread. + + A tree GitHub truncated is unread too. Its ceiling on a recursive read is about 100,000 + entries, which no fleet repository approaches. A directory entry is left out, a submodule + kept, since the compare lists a submodule moving as a change. + """ + tree = reread_json( + f"repos/{owner}/{repo}/git/trees/{commit}?recursive=1", + r'if (.tree | type) == "array" and (.truncated | type) == "boolean" ' + r'then ({truncated, entries: [.tree[] | select(.type != "tree") | [.path, .mode, .sha]]} ' + r"| @json) else empty end", + ) + if not isinstance(tree, dict) or tree.get("truncated") is not False: return None - if not isinstance(names, list) or len(names) >= COMPARE_FILE_CAP: + entries = tree.get("entries") + if not isinstance(entries, list): return None - if not all(isinstance(n, str) and n for n in names): + paths: dict[str, tuple[str, str]] = {} + for entry in entries: + if not ( + isinstance(entry, list) + and len(entry) == 3 + and all(isinstance(e, str) and e for e in entry) + ): + return None + paths[entry[0]] = (entry[1], entry[2]) + return paths + + +@functools.cache +def tree_changed_at(owner: str, repo: str, merge_base: str, commit: str) -> frozenset[str] | None: + """The paths `commit` changes against `merge_base`, read past the compare's file cap. + + The compare caps its file list at 300 on every page. Measured on a 308-file comparison, the + first page carried 300 files and the second none at all, so no paging of it reaches further. + The trees either side do: the commit's own and the merge base's, each read whole and diffed + here. On that comparison this named exactly the paths `git diff --no-renames --name-only` + does, 326 of them. That is not the compare's own reading, which names a rename once by its + new path, so `carry_holds` reads both commits this way or neither, never one of each. + + None where either tree was unread. Memoized for the run, for the reason `changed_at` is. + """ + before = tree_at(owner, repo, merge_base) + after = tree_at(owner, repo, commit) + if before is None or after is None: return None - return frozenset(names) + return frozenset(p for p in before.keys() | after.keys() if before.get(p) != after.get(p)) def carry_holds(owner: str, repo: str, pr: dict, carried_from: str) -> bool | None: @@ -2093,6 +2169,10 @@ def carry_holds(owner: str, repo: str, pr: dict, carried_from: str) -> bool | No None where the comparison could not be made at all, which the caller reads as a refusal rather than as a pass. A bound that fell open when it could not be measured would be the unbounded carry again, reached by a different route. + + A compare at its cap on one side only is a definite no, since the other side's list is whole + and shorter than the cap, so the two sets differ in size. At the cap on both, the change set + is read again from the trees, for both commits. """ head = pr.get("headRefOid") or "" base = pr.get("baseRefName") or "" @@ -2100,7 +2180,15 @@ def carry_holds(owner: str, repo: str, pr: dict, carried_from: str) -> bool | No current = changed_at(owner, repo, base, head) if earlier is None or current is None: return None - return earlier == current + if isinstance(earlier, frozenset) and isinstance(current, frozenset): + return earlier == current + if isinstance(earlier, frozenset) or isinstance(current, frozenset): + return False + before = tree_changed_at(owner, repo, earlier.merge_base, carried_from) + after = tree_changed_at(owner, repo, current.merge_base, head) + if before is None or after is None: + return None + return before == after def second_format(body: str) -> bool: diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index 4e24bc3da..dba1cd8e2 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -500,6 +500,8 @@ def setUp(self) -> None: # Without this a case inherits another's compares and the suite becomes order-dependent. pr_review.changed_at.cache_clear() self.addCleanup(pr_review.changed_at.cache_clear) + pr_review.tree_changed_at.cache_clear() + self.addCleanup(pr_review.tree_changed_at.cache_clear) pr_review.run_cause.cache_clear() self.addCleanup(pr_review.run_cause.cache_clear) self.enterContext( @@ -2744,7 +2746,8 @@ def rest(path: str, jq: str | None = None) -> subprocess.CompletedProcess: names = at.get(path.rsplit("...", 1)[-1]) if names is None: return subprocess.CompletedProcess([], 1, "", "no read for this commit") - return subprocess.CompletedProcess([], 0, json.dumps(names), "") + shape = {"files": names, "merge_base": "c" * 40} + return subprocess.CompletedProcess([], 0, json.dumps(shape), "") return subprocess.CompletedProcess([], 0, "1 1", "") self.calls: list[str] = [] @@ -3059,11 +3062,13 @@ def test_the_verdict_names_which_of_the_three_states_it_reached(self) -> None: self.assertIn("one of three things", text) self.assertNotIn("no round on this pull request states changed-file coverage", text) - def test_a_change_set_at_the_compare_cap_reads_as_unreadable(self) -> None: - """The API caps its file list and flags nothing, so a set at the cap proves nothing.""" + def test_a_change_set_at_the_compare_cap_reads_as_capped(self) -> None: + """The API caps its file list and flags nothing, so a set at the cap is not a set.""" capped = [f"f{n}.py" for n in range(pr_review.COMPARE_FILE_CAP)] with self.compare(**{OLD: capped}): - self.assertIsNone(pr_review.changed_at("o", "r", "develop", OLD)) + self.assertEqual( + pr_review.Capped("c" * 40), pr_review.changed_at("o", "r", "develop", OLD) + ) def test_a_pull_request_that_changes_nothing_is_not_an_unreadable_compare(self) -> None: """An empty set and a compare that never happened take opposite decisions.""" @@ -3296,7 +3301,7 @@ def test_a_compare_that_failed_is_read_again_before_it_refuses_the_carry(self) - replies = iter( [ subprocess.CompletedProcess([], 1, "", "gh: Bad Gateway (HTTP 502)"), - subprocess.CompletedProcess([], 0, json.dumps(["a.py"]), ""), + subprocess.CompletedProcess([], 0, json.dumps({"files": ["a.py"]}), ""), ] ) with ( @@ -3406,6 +3411,131 @@ def test_no_table_anywhere_says_so(self) -> None: self.assertIn("no earlier round carries one either", out) +class TestCarryPastTheCompareCeiling(CarryCase): + """A change set at the compare's 300-file cap is read from the trees either side instead. + + The constructed case is a promotion changing 400 files, its first round carrying a full table + and a later round on a new head re-reviewing a one-line delta with no table. Both compares + return 300 files, so the change set is read past them from the trees. + """ + + BASE = "c" * 40 + MANY = tuple(f"src/f{n:03}.py" for n in range(400)) + + def trees( + self, + head: dict[str, str] | None = None, + compared: dict[str, list[str]] | None = None, + merge_base: str | None = None, + ) -> mock._patch: + """Answer every compare at the cap, naming its merge base, and the trees either side. + + The merge base keeps every path at one blob and `OLD` moves all of `MANY`. The head + moves one of them again by default, the one-line delta past `OLD`, so the change set is + the same at both. `head` replaces the head's tree, an empty one standing for a read that + failed. `compared` gives a commit a list under the cap instead, and `merge_base` + replaces the merge base the compares name. + """ + base_tree = {p: "1" * 40 for p in [*self.MANY, "README.md"]} + old_tree = {**base_tree, **{p: "2" * 40 for p in self.MANY}} + head_tree = {**old_tree, self.MANY[0]: "3" * 40} if head is None else head + served = {self.BASE: base_tree, OLD: old_tree, HEAD: head_tree} + named = self.BASE if merge_base is None else merge_base + + def rest(path: str, jq: str | None = None) -> subprocess.CompletedProcess: + self.calls.append(path) + if "/git/trees/" in path: + tree = served.get(path.split("/git/trees/", 1)[1].split("?", 1)[0]) + if not tree: + return subprocess.CompletedProcess([], 1, "", "no tree for this commit") + rows = [[p, "100644", blob] for p, blob in tree.items()] + shape = {"truncated": False, "entries": rows} + return subprocess.CompletedProcess([], 0, json.dumps(shape), "") + if "/compare/" not in path: + return subprocess.CompletedProcess([], 1, "", "not a compare") + if jq and "@json" in jq: + commit = path.rsplit("...", 1)[-1] + names = (compared or {}).get(commit, list(self.MANY[: pr_review.COMPARE_FILE_CAP])) + answer = {"files": names, "merge_base": named} + return subprocess.CompletedProcess([], 0, json.dumps(answer), "") + return subprocess.CompletedProcess([], 0, "1 1", "") + + self.calls: list[str] = [] + return mock.patch.object(pr_review, "gh_rest", side_effect=rest) + + def promotion(self, first: str | None = None) -> dict: + body = summarized(list(self.MANY), covers="") if first is None else first + return payload( + [ + review(oid=OLD, body=body, at=EARLY, rid="A"), + review(oid=HEAD, body=self.NONE, at=LATE, rid="B"), + ], + files=list(self.MANY), + ) + + def verdict(self, pr: dict) -> tuple[int, str]: + with contextlib.redirect_stdout(io.StringIO()): + code = pr_review.report_verdict(pr, "o", "r") + out, _ = pr_review.digest("o", "r", 7, pr=pr) + return code, out + + def test_a_400_file_promotion_carries_its_table_onto_an_unchanged_change_set(self) -> None: + with self.trees(): + code, out = self.verdict(self.promotion()) + self.assertEqual(0, code) + self.assertIn("coverage=carried:table ", out) + self.assertEqual(4, sum("/git/trees/" in c for c in self.calls)) + + def test_a_400_file_promotion_carries_a_statement_onto_an_unchanged_change_set(self) -> None: + with self.trees(): + code, out = self.verdict(self.promotion(first=self.FULL)) + self.assertEqual(0, code) + self.assertIn("coverage=carried:full", out) + + def test_a_change_set_past_the_cap_that_moved_is_not_carried(self) -> None: + moved = { + **{p: "1" * 40 for p in [*self.MANY, "README.md"]}, + **{p: "2" * 40 for p in self.MANY}, + "README.md": "3" * 40, + } + with self.trees(head=moved): + code, out = self.verdict(self.promotion()) + self.assertEqual(45, code) + self.assertIn("changes a different set of files at the two commits", out) + + def test_the_cap_on_one_side_only_is_a_different_set_without_a_tree_read(self) -> None: + for whole in (OLD, HEAD): + with self.subTest(whole=whole), self.trees(compared={whole: ["a.py"]}): + pr_review.changed_at.cache_clear() + self.assertIs(False, pr_review.carry_holds("o", "r", self.promotion(), OLD)) + self.assertFalse(any("/git/trees/" in c for c in self.calls)) + + def test_a_tree_read_that_failed_reads_as_unreadable(self) -> None: + with self.trees(head={}): + code, out = self.verdict(self.promotion()) + self.assertEqual(45, code) + self.assertIn("the change set could not be read at both commits", out) + + def test_a_compare_at_the_cap_naming_no_merge_base_reads_no_tree(self) -> None: + with self.trees(merge_base=""): + self.assertIsNone(pr_review.carry_holds("o", "r", self.promotion(), OLD)) + self.assertFalse(any("/git/trees/" in c for c in self.calls)) + + def test_the_tree_reading_names_both_paths_of_a_rename(self) -> None: + """A rename is a removal and an addition here, where the compare names its new path.""" + renamed = {p: "1" * 40 for p in [*self.MANY[1:], "README.md", "moved.py"]} + with self.trees(head=renamed): + changed = pr_review.tree_changed_at("o", "r", self.BASE, HEAD) + self.assertEqual(frozenset({self.MANY[0], "moved.py"}), changed) + + def test_a_truncated_tree_reads_as_unread(self) -> None: + cut = json.dumps({"truncated": True, "entries": []}) + with mock.patch.object( + pr_review, "gh_rest", return_value=subprocess.CompletedProcess([], 0, cut, "") + ): + self.assertIsNone(pr_review.tree_at("o", "r", HEAD)) + + class TestTheDeltaSinceTheCarriedRound(unittest.TestCase): """What changed between the round that stated coverage and the head, reported not judged.""" @@ -7830,7 +7960,7 @@ def test_a_held_poll_s_own_full_read_is_read_again_where_its_file_list_came_back landed = self.into(payload(rounds, checks=[check()], files=[])) served = self.answer(held, held, landed, landed, self.into(payload(rounds, files=["a.py"]))) self.wire_history([hist_review(7, OVERVIEW + "\n" + COVERED)]) - compare = subprocess.CompletedProcess([], 0, json.dumps(["a.py"]), "") + compare = subprocess.CompletedProcess([], 0, json.dumps({"files": ["a.py"]}), "") with ( mock.patch.object(pr_review, "READ_RETRY_DELAYS", (0,)), mock.patch.object(pr_review, "gh_rest", return_value=compare), From 16e20cd705126acab523207f1febffe0c1aca7a8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 04:03:28 -0700 Subject: [PATCH 10/24] Close the Remaining Blocked-Promotion Gaps in the Unattended Worker (#2792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes the gaps #2614 still had open in `unattended-handoff` "The Worker". #2638 already closed the promotion-body race (gap 3) and added the before-merge `closedAt` re-read, so this change covers the other three. - **A blocked-promotion read that cannot run returns `STOP`.** Before this, a failed `decision` read counted as "not blocked" and the worker went on to the promotion. Now a read that did not run says nothing either way, and a `handoff.py chain` refusal counts as such a read. - **A parked lane holds the promotion until the lane closes, whether its decision is answered or not.** Answering a decision closes the issue and takes the `decision` label off, so the open-`decision` read stops seeing it before the parked lane has applied the answer. Step 3 now also reads the open handoffs. It walks each other `auto-*` track with `handoff.py chain`, reading every link's body and comments. Where a parking comment names a decision issue that says it blocks the open promotion, the round narrows to develop, whatever that issue's state. The before-merge re-read repeats both reads. It narrows only on an open `decision` issue or a lane hold, so #2638's `closedAt` park can still be reached. - **Worker step 1 walks its own lane back the whole way.** It lists the links with `chain`, raising `--limit` only while the cap is its one notice, and reads each link with `gh issue view --json body,comments`. Any other notice, or a nonzero exit, returns `STOP`. A lane parked and handed back more than once keeps its earlier answer in an older link. - **Step 5 names the maintainer-only action that parks:** a review stall with a human or team reviewer requested, as `pr-review-conduct` "Expected review loop" sends it. A stall still pending after the post-clear wait, and a `REQUEST_NOT_RECORDED` wait, return `STOP` instead, since a later round on another issue would hit the same reviewer. This follows "Return Lines". Generated copies were rebuilt with `scripts/build_dist.py`, and `--check` is clean. ## Local review Five recorded `local-strict-review` passes at the `frontier` tier. - **Round 1 (3 findings, all fixed):** - The re-read counted a closed decision as a hold. - The lane read looked back only one link. - A stall after a cleared request had no stated route between parking and `STOP`. - **Round 2 (2 findings, both fixed):** - The worker's own lane was still read only one link back in step 1. - A `chain` refusal was left undefined. - **Round 3:** no findings. - **Round 4, on the fix for the Copilot finding about `chain` (2 findings, both fixed):** - Notices other than the `--limit` cap, and a nonzero exit, were not routed to `STOP`. - `gh issue view --comments` prints no body when piped, so links are now read with `--json body,comments`. - **Round 5:** no findings. The opt-in `prose_lint.py --check sentence-length` check flags 12 sentences over 25 words in steps 1, 3 and 5. Each is class `style`, and they are declined as preferences, since that check is not part of the default gate and `fleet-code-review` "Review the Change" item 5 omits preferences. ## Verification - `scripts/prose_lint.py --diff origin/develop`: clean. - `scripts/build_dist.py --check`: clean. - `spec/validate.py`: OK. - `scripts/repo_gate.py --check eol`: clean. - markdownlint, editorconfig-checker and cspell through `scripts/docker_lint.py`: clean. Closes on promotion: #2614 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/unattended-handoff/SKILL.md | 84 ++++++++++++------- .../.source-digests/unattended-handoff | 2 +- .../skills/unattended-handoff/SKILL.md | 84 ++++++++++++------- .github/skills/unattended-handoff/SKILL.md | 84 ++++++++++++------- 4 files changed, 157 insertions(+), 97 deletions(-) diff --git a/.agents/skills/unattended-handoff/SKILL.md b/.agents/skills/unattended-handoff/SKILL.md index 79939de8b..d12ee395b 100644 --- a/.agents/skills/unattended-handoff/SKILL.md +++ b/.agents/skills/unattended-handoff/SKILL.md @@ -214,40 +214,55 @@ does not qualify, since skipping one costs nothing and a guess costs a revert an 1. **Resume the handoff** with `handoff.py resume --repo "/" --track ""`, then read its comments with `gh issue view "" --repo "/" --comments`, since `resume` prints only the body and a parked lane's state is in its parking comment. A lane handed back by - an attended session has a closed predecessor holding that comment, so read the predecessor's - comments too. Where the parking comment names the decision issue the lane waits on, read the - answer recorded there and follow it, since it is what unblocked the lane. A lesson issue the - comment also names, per step 6, is its own work rather than this lane's, so the worker neither - follows it nor parks on it. Re-derive live state rather than trusting any of them, per - `session-handoff` "Resuming". + an attended session holds that comment in a closed link, further back where it parked more than + once, so walk it. List its links with `handoff.py chain --repo "/" --track + ""`, raising `--limit` while its only notice names the `--limit` cap, and read each link + with `gh issue view "" --repo "/" --json body,comments`, since `chain` prints one + summary line per link. Any other notice says the walk missed part of the lane, so it returns + `STOP`, as a nonzero exit from `chain` does. Where a parking comment names a decision issue the + lane waits on, read the answer recorded there and follow it, since it is what unblocked the lane. + A lesson issue the comment also names, per step 6, is its own work rather than this lane's, so + the worker neither follows it nor parks on it. Re-derive live state rather than trusting any of + them, per `session-handoff` "Resuming". 2. **Isolate** in a worktree of its own, per `repo-worktree`, on the branch the handoff names or on `feature/`. 3. **Fix and drive.** Run `local-strict-review` before every push, and drive the pull request with `drive-pr` to develop, its body carrying `Closes on promotion: #`. Every Merge Gate item - other than the permission still has to hold, on this pull request and on any promotion. - **Under `main` or `release`, check for a blocked promotion before opening or driving one.** Note - the time first, in UTC as `closedAt` reports it, so a decision closed while the read runs counts - as closed after it. Read the open `decision` issues under picker step 1's full-page check, with - `gh issue list --repo "/" --label decision --state open --limit 100 --json + other than the permission still has to hold, on this pull request and on any promotion. **Under + `main` or `release`, check for a blocked promotion before opening or driving one.** Note the time + first, in UTC as `closedAt` reports it, so a decision closed while the read runs counts as closed + after it. Read the open `decision` issues under picker step 1's full-page check, with `gh issue + list --repo "/" --label decision --state open --limit 100 --json number,body,comments`. Where one of them states, in its body or in a comment, that it blocks the open develop -> main pull request, as "Parking" step 2 has it state, the round narrows to - develop. The worker then merges, opens, and releases no promotion while that decision issue is - open. It instead brings that promotion's body up to a `Fixes` line for every issue develop - fixes, `#` included, per `backlog-burndown` "Assembling the Promotion Body", so each - closes when the promotion merges. - It then closes the lane out, naming the decision issue in the close-out comment. Where none - does, continue to the promotion pull request, its body carrying a `Fixes` line for every issue - develop fixes, assembled per that same section, and hand it to `merge-and-release`, merging only - under `main` and merging and releasing under `release`. **Repeat the blocked-promotion read - immediately before each head of the promotion merges**, a head `merge-and-release` re-gates - included. Run it with `--state all` and with `state,closedAt` added to `--json`, never with a - `--search` query, since search can lag a close. Where one blocking that promotion is open, the - round narrows to develop as above. Where one blocking that promotion has a `closedAt` at or - after the time noted before the first read, park. That answer may not be to merge, so the - `decision` issue the park files asks whether it lets the promotion merge. Exit `45` on a - promotion blocks no merge where the promotion meets the one case `pr-review-conduct` Merge Gate - item 2 states. Such a promotion merges with no `decision` issue, - and the worker records that item's evidence on the handoff. Any other shortfall parks. + develop. **A lane parked on that promotion holds it until the lane closes, answered or not.** An + answer closes the decision issue and takes its label off, so the read above stops seeing it + before the lane holding the answer has applied it. Read the open handoffs under the same + full-page check, with `gh issue list --repo "/" --label handoff --state open --limit + 100 --json number,body,comments`, and keep those on an `auto-*` track other than this worker's + own. A lane parked and handed back more than once holds the answer in an older link, so walk each + kept track back as step 1 walks the worker's own, reading every link's body and comments. A walk + step 1 would return `STOP` on is a read that cannot run. Where any of them names a decision issue + that states it blocks the open promotion, whatever that issue's state, the round narrows to + develop as well. The worker then merges, opens, and releases no promotion while either holds. It + instead brings that promotion's body up to a `Fixes` line for every issue develop fixes, + `#` included, per `backlog-burndown` "Assembling the Promotion Body", so each closes when + the promotion merges. It then closes the lane out, naming the decision issue in the close-out + comment. Where neither holds, continue to the promotion pull request, its body carrying a `Fixes` + line for every issue develop fixes, assembled per that same section, and hand it to + `merge-and-release`, merging only under `main` and merging and releasing under `release`. **A + read this step names that cannot run returns `STOP`** rather than continuing, since a read that + did not run shows nothing about whether the promotion is blocked. **Repeat both blocked-promotion + reads immediately before each head of the promotion merges**, a head `merge-and-release` re-gates + included. Run the `decision` read with `--state all` and with `state,closedAt` added to `--json`, + never with a `--search` query, since search can lag a close. Where a `decision` issue blocking + that promotion is open, or the lane read finds the promotion held, the round narrows to develop. + Otherwise, where a `decision` issue blocking that promotion has a `closedAt` at or after the time + noted before the first read, park. That answer may not be to merge, so the `decision` issue the + park files asks whether it lets the promotion merge. Exit `45` on a promotion blocks no merge + where the promotion meets the one case `pr-review-conduct` Merge Gate item 2 states. Such a + promotion merges with no `decision` issue, and the worker records that item's evidence on the + handoff. Any other shortfall parks. 4. **Wait in the foreground.** Each wait is one bounded command such as `pr_review.py wait`, run in the worker's own turn. The `local-strict-review` pass is a wait like any other, so the worker replies only after its reviewer has reported. It dispatches the reviewer in the background only @@ -258,10 +273,15 @@ does not qualify, since skipping one costs nothing and a guess costs a revert an on a line that no longer describes it. 5. **Park at the first decision**, per "Parking" below, filing any lesson per step 6 before the parking comment so the comment can name it. That includes a merge the harness refuses after one - retry, which is parked as ready to merge rather than routed around. Otherwise a worker parks - only on a choice the maintainer alone can make. Anything with a determined outcome is applied, - or handed to the pull request's review as `local-strict-review` "Disposing of Findings" hands - it a spent edit budget's remaining findings. + retry, which is parked as ready to merge rather than routed around. Otherwise a worker parks only + on a choice the maintainer alone can make, or on an action only the maintainer can take that + `pr-review-conduct` sends to the maintainer. One is a review stall with a human or team reviewer + requested, which its "Expected review loop" sends there. Two others it sends there return `STOP` + instead, since a later round on another issue would meet the same reviewer. One is a stall still + pending after the wait that follows clearing the request. The other is a wait ending + `REQUEST_NOT_RECORDED`, which that section reads as an exhausted reviewer allowance. Anything + with a determined outcome is applied, or handed to the pull request's review as + `local-strict-review` "Disposing of Findings" hands it a spent edit budget's remaining findings. 6. **File any lesson for the maintainer.** A lesson a future agent must honor is rule text, which is the maintainer's to judge and no one is present to judge it, so file it as an issue carrying `decision`, stating the proposed rule and where it would go, with the choices as its options in diff --git a/.claude-plugin/fleet-skills/.source-digests/unattended-handoff b/.claude-plugin/fleet-skills/.source-digests/unattended-handoff index a2eefac9c..5fdeec54a 100644 --- a/.claude-plugin/fleet-skills/.source-digests/unattended-handoff +++ b/.claude-plugin/fleet-skills/.source-digests/unattended-handoff @@ -1 +1 @@ -5a890eccff5ef435 +bcb7fcc3345bb6aa diff --git a/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md b/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md index 79939de8b..d12ee395b 100644 --- a/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md @@ -214,40 +214,55 @@ does not qualify, since skipping one costs nothing and a guess costs a revert an 1. **Resume the handoff** with `handoff.py resume --repo "/" --track ""`, then read its comments with `gh issue view "" --repo "/" --comments`, since `resume` prints only the body and a parked lane's state is in its parking comment. A lane handed back by - an attended session has a closed predecessor holding that comment, so read the predecessor's - comments too. Where the parking comment names the decision issue the lane waits on, read the - answer recorded there and follow it, since it is what unblocked the lane. A lesson issue the - comment also names, per step 6, is its own work rather than this lane's, so the worker neither - follows it nor parks on it. Re-derive live state rather than trusting any of them, per - `session-handoff` "Resuming". + an attended session holds that comment in a closed link, further back where it parked more than + once, so walk it. List its links with `handoff.py chain --repo "/" --track + ""`, raising `--limit` while its only notice names the `--limit` cap, and read each link + with `gh issue view "" --repo "/" --json body,comments`, since `chain` prints one + summary line per link. Any other notice says the walk missed part of the lane, so it returns + `STOP`, as a nonzero exit from `chain` does. Where a parking comment names a decision issue the + lane waits on, read the answer recorded there and follow it, since it is what unblocked the lane. + A lesson issue the comment also names, per step 6, is its own work rather than this lane's, so + the worker neither follows it nor parks on it. Re-derive live state rather than trusting any of + them, per `session-handoff` "Resuming". 2. **Isolate** in a worktree of its own, per `repo-worktree`, on the branch the handoff names or on `feature/`. 3. **Fix and drive.** Run `local-strict-review` before every push, and drive the pull request with `drive-pr` to develop, its body carrying `Closes on promotion: #`. Every Merge Gate item - other than the permission still has to hold, on this pull request and on any promotion. - **Under `main` or `release`, check for a blocked promotion before opening or driving one.** Note - the time first, in UTC as `closedAt` reports it, so a decision closed while the read runs counts - as closed after it. Read the open `decision` issues under picker step 1's full-page check, with - `gh issue list --repo "/" --label decision --state open --limit 100 --json + other than the permission still has to hold, on this pull request and on any promotion. **Under + `main` or `release`, check for a blocked promotion before opening or driving one.** Note the time + first, in UTC as `closedAt` reports it, so a decision closed while the read runs counts as closed + after it. Read the open `decision` issues under picker step 1's full-page check, with `gh issue + list --repo "/" --label decision --state open --limit 100 --json number,body,comments`. Where one of them states, in its body or in a comment, that it blocks the open develop -> main pull request, as "Parking" step 2 has it state, the round narrows to - develop. The worker then merges, opens, and releases no promotion while that decision issue is - open. It instead brings that promotion's body up to a `Fixes` line for every issue develop - fixes, `#` included, per `backlog-burndown` "Assembling the Promotion Body", so each - closes when the promotion merges. - It then closes the lane out, naming the decision issue in the close-out comment. Where none - does, continue to the promotion pull request, its body carrying a `Fixes` line for every issue - develop fixes, assembled per that same section, and hand it to `merge-and-release`, merging only - under `main` and merging and releasing under `release`. **Repeat the blocked-promotion read - immediately before each head of the promotion merges**, a head `merge-and-release` re-gates - included. Run it with `--state all` and with `state,closedAt` added to `--json`, never with a - `--search` query, since search can lag a close. Where one blocking that promotion is open, the - round narrows to develop as above. Where one blocking that promotion has a `closedAt` at or - after the time noted before the first read, park. That answer may not be to merge, so the - `decision` issue the park files asks whether it lets the promotion merge. Exit `45` on a - promotion blocks no merge where the promotion meets the one case `pr-review-conduct` Merge Gate - item 2 states. Such a promotion merges with no `decision` issue, - and the worker records that item's evidence on the handoff. Any other shortfall parks. + develop. **A lane parked on that promotion holds it until the lane closes, answered or not.** An + answer closes the decision issue and takes its label off, so the read above stops seeing it + before the lane holding the answer has applied it. Read the open handoffs under the same + full-page check, with `gh issue list --repo "/" --label handoff --state open --limit + 100 --json number,body,comments`, and keep those on an `auto-*` track other than this worker's + own. A lane parked and handed back more than once holds the answer in an older link, so walk each + kept track back as step 1 walks the worker's own, reading every link's body and comments. A walk + step 1 would return `STOP` on is a read that cannot run. Where any of them names a decision issue + that states it blocks the open promotion, whatever that issue's state, the round narrows to + develop as well. The worker then merges, opens, and releases no promotion while either holds. It + instead brings that promotion's body up to a `Fixes` line for every issue develop fixes, + `#` included, per `backlog-burndown` "Assembling the Promotion Body", so each closes when + the promotion merges. It then closes the lane out, naming the decision issue in the close-out + comment. Where neither holds, continue to the promotion pull request, its body carrying a `Fixes` + line for every issue develop fixes, assembled per that same section, and hand it to + `merge-and-release`, merging only under `main` and merging and releasing under `release`. **A + read this step names that cannot run returns `STOP`** rather than continuing, since a read that + did not run shows nothing about whether the promotion is blocked. **Repeat both blocked-promotion + reads immediately before each head of the promotion merges**, a head `merge-and-release` re-gates + included. Run the `decision` read with `--state all` and with `state,closedAt` added to `--json`, + never with a `--search` query, since search can lag a close. Where a `decision` issue blocking + that promotion is open, or the lane read finds the promotion held, the round narrows to develop. + Otherwise, where a `decision` issue blocking that promotion has a `closedAt` at or after the time + noted before the first read, park. That answer may not be to merge, so the `decision` issue the + park files asks whether it lets the promotion merge. Exit `45` on a promotion blocks no merge + where the promotion meets the one case `pr-review-conduct` Merge Gate item 2 states. Such a + promotion merges with no `decision` issue, and the worker records that item's evidence on the + handoff. Any other shortfall parks. 4. **Wait in the foreground.** Each wait is one bounded command such as `pr_review.py wait`, run in the worker's own turn. The `local-strict-review` pass is a wait like any other, so the worker replies only after its reviewer has reported. It dispatches the reviewer in the background only @@ -258,10 +273,15 @@ does not qualify, since skipping one costs nothing and a guess costs a revert an on a line that no longer describes it. 5. **Park at the first decision**, per "Parking" below, filing any lesson per step 6 before the parking comment so the comment can name it. That includes a merge the harness refuses after one - retry, which is parked as ready to merge rather than routed around. Otherwise a worker parks - only on a choice the maintainer alone can make. Anything with a determined outcome is applied, - or handed to the pull request's review as `local-strict-review` "Disposing of Findings" hands - it a spent edit budget's remaining findings. + retry, which is parked as ready to merge rather than routed around. Otherwise a worker parks only + on a choice the maintainer alone can make, or on an action only the maintainer can take that + `pr-review-conduct` sends to the maintainer. One is a review stall with a human or team reviewer + requested, which its "Expected review loop" sends there. Two others it sends there return `STOP` + instead, since a later round on another issue would meet the same reviewer. One is a stall still + pending after the wait that follows clearing the request. The other is a wait ending + `REQUEST_NOT_RECORDED`, which that section reads as an exhausted reviewer allowance. Anything + with a determined outcome is applied, or handed to the pull request's review as + `local-strict-review` "Disposing of Findings" hands it a spent edit budget's remaining findings. 6. **File any lesson for the maintainer.** A lesson a future agent must honor is rule text, which is the maintainer's to judge and no one is present to judge it, so file it as an issue carrying `decision`, stating the proposed rule and where it would go, with the choices as its options in diff --git a/.github/skills/unattended-handoff/SKILL.md b/.github/skills/unattended-handoff/SKILL.md index 79939de8b..d12ee395b 100644 --- a/.github/skills/unattended-handoff/SKILL.md +++ b/.github/skills/unattended-handoff/SKILL.md @@ -214,40 +214,55 @@ does not qualify, since skipping one costs nothing and a guess costs a revert an 1. **Resume the handoff** with `handoff.py resume --repo "/" --track ""`, then read its comments with `gh issue view "" --repo "/" --comments`, since `resume` prints only the body and a parked lane's state is in its parking comment. A lane handed back by - an attended session has a closed predecessor holding that comment, so read the predecessor's - comments too. Where the parking comment names the decision issue the lane waits on, read the - answer recorded there and follow it, since it is what unblocked the lane. A lesson issue the - comment also names, per step 6, is its own work rather than this lane's, so the worker neither - follows it nor parks on it. Re-derive live state rather than trusting any of them, per - `session-handoff` "Resuming". + an attended session holds that comment in a closed link, further back where it parked more than + once, so walk it. List its links with `handoff.py chain --repo "/" --track + ""`, raising `--limit` while its only notice names the `--limit` cap, and read each link + with `gh issue view "" --repo "/" --json body,comments`, since `chain` prints one + summary line per link. Any other notice says the walk missed part of the lane, so it returns + `STOP`, as a nonzero exit from `chain` does. Where a parking comment names a decision issue the + lane waits on, read the answer recorded there and follow it, since it is what unblocked the lane. + A lesson issue the comment also names, per step 6, is its own work rather than this lane's, so + the worker neither follows it nor parks on it. Re-derive live state rather than trusting any of + them, per `session-handoff` "Resuming". 2. **Isolate** in a worktree of its own, per `repo-worktree`, on the branch the handoff names or on `feature/`. 3. **Fix and drive.** Run `local-strict-review` before every push, and drive the pull request with `drive-pr` to develop, its body carrying `Closes on promotion: #`. Every Merge Gate item - other than the permission still has to hold, on this pull request and on any promotion. - **Under `main` or `release`, check for a blocked promotion before opening or driving one.** Note - the time first, in UTC as `closedAt` reports it, so a decision closed while the read runs counts - as closed after it. Read the open `decision` issues under picker step 1's full-page check, with - `gh issue list --repo "/" --label decision --state open --limit 100 --json + other than the permission still has to hold, on this pull request and on any promotion. **Under + `main` or `release`, check for a blocked promotion before opening or driving one.** Note the time + first, in UTC as `closedAt` reports it, so a decision closed while the read runs counts as closed + after it. Read the open `decision` issues under picker step 1's full-page check, with `gh issue + list --repo "/" --label decision --state open --limit 100 --json number,body,comments`. Where one of them states, in its body or in a comment, that it blocks the open develop -> main pull request, as "Parking" step 2 has it state, the round narrows to - develop. The worker then merges, opens, and releases no promotion while that decision issue is - open. It instead brings that promotion's body up to a `Fixes` line for every issue develop - fixes, `#` included, per `backlog-burndown` "Assembling the Promotion Body", so each - closes when the promotion merges. - It then closes the lane out, naming the decision issue in the close-out comment. Where none - does, continue to the promotion pull request, its body carrying a `Fixes` line for every issue - develop fixes, assembled per that same section, and hand it to `merge-and-release`, merging only - under `main` and merging and releasing under `release`. **Repeat the blocked-promotion read - immediately before each head of the promotion merges**, a head `merge-and-release` re-gates - included. Run it with `--state all` and with `state,closedAt` added to `--json`, never with a - `--search` query, since search can lag a close. Where one blocking that promotion is open, the - round narrows to develop as above. Where one blocking that promotion has a `closedAt` at or - after the time noted before the first read, park. That answer may not be to merge, so the - `decision` issue the park files asks whether it lets the promotion merge. Exit `45` on a - promotion blocks no merge where the promotion meets the one case `pr-review-conduct` Merge Gate - item 2 states. Such a promotion merges with no `decision` issue, - and the worker records that item's evidence on the handoff. Any other shortfall parks. + develop. **A lane parked on that promotion holds it until the lane closes, answered or not.** An + answer closes the decision issue and takes its label off, so the read above stops seeing it + before the lane holding the answer has applied it. Read the open handoffs under the same + full-page check, with `gh issue list --repo "/" --label handoff --state open --limit + 100 --json number,body,comments`, and keep those on an `auto-*` track other than this worker's + own. A lane parked and handed back more than once holds the answer in an older link, so walk each + kept track back as step 1 walks the worker's own, reading every link's body and comments. A walk + step 1 would return `STOP` on is a read that cannot run. Where any of them names a decision issue + that states it blocks the open promotion, whatever that issue's state, the round narrows to + develop as well. The worker then merges, opens, and releases no promotion while either holds. It + instead brings that promotion's body up to a `Fixes` line for every issue develop fixes, + `#` included, per `backlog-burndown` "Assembling the Promotion Body", so each closes when + the promotion merges. It then closes the lane out, naming the decision issue in the close-out + comment. Where neither holds, continue to the promotion pull request, its body carrying a `Fixes` + line for every issue develop fixes, assembled per that same section, and hand it to + `merge-and-release`, merging only under `main` and merging and releasing under `release`. **A + read this step names that cannot run returns `STOP`** rather than continuing, since a read that + did not run shows nothing about whether the promotion is blocked. **Repeat both blocked-promotion + reads immediately before each head of the promotion merges**, a head `merge-and-release` re-gates + included. Run the `decision` read with `--state all` and with `state,closedAt` added to `--json`, + never with a `--search` query, since search can lag a close. Where a `decision` issue blocking + that promotion is open, or the lane read finds the promotion held, the round narrows to develop. + Otherwise, where a `decision` issue blocking that promotion has a `closedAt` at or after the time + noted before the first read, park. That answer may not be to merge, so the `decision` issue the + park files asks whether it lets the promotion merge. Exit `45` on a promotion blocks no merge + where the promotion meets the one case `pr-review-conduct` Merge Gate item 2 states. Such a + promotion merges with no `decision` issue, and the worker records that item's evidence on the + handoff. Any other shortfall parks. 4. **Wait in the foreground.** Each wait is one bounded command such as `pr_review.py wait`, run in the worker's own turn. The `local-strict-review` pass is a wait like any other, so the worker replies only after its reviewer has reported. It dispatches the reviewer in the background only @@ -258,10 +273,15 @@ does not qualify, since skipping one costs nothing and a guess costs a revert an on a line that no longer describes it. 5. **Park at the first decision**, per "Parking" below, filing any lesson per step 6 before the parking comment so the comment can name it. That includes a merge the harness refuses after one - retry, which is parked as ready to merge rather than routed around. Otherwise a worker parks - only on a choice the maintainer alone can make. Anything with a determined outcome is applied, - or handed to the pull request's review as `local-strict-review` "Disposing of Findings" hands - it a spent edit budget's remaining findings. + retry, which is parked as ready to merge rather than routed around. Otherwise a worker parks only + on a choice the maintainer alone can make, or on an action only the maintainer can take that + `pr-review-conduct` sends to the maintainer. One is a review stall with a human or team reviewer + requested, which its "Expected review loop" sends there. Two others it sends there return `STOP` + instead, since a later round on another issue would meet the same reviewer. One is a stall still + pending after the wait that follows clearing the request. The other is a wait ending + `REQUEST_NOT_RECORDED`, which that section reads as an exhausted reviewer allowance. Anything + with a determined outcome is applied, or handed to the pull request's review as + `local-strict-review` "Disposing of Findings" hands it a spent edit budget's remaining findings. 6. **File any lesson for the maintainer.** A lesson a future agent must honor is rule text, which is the maintainer's to judge and no one is present to judge it, so file it as an issue carrying `decision`, stating the proposed rule and where it would go, with the choices as its options in From c8196f47e1251999877ea53799964d6d34ee734f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 04:25:56 -0700 Subject: [PATCH 11/24] Let the Unattended Orchestrator Retry a Transient Dispatch Failure (#2794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Before this change, the `unattended-handoff` loop had no path for the orchestrator's own dispatch failing, so a transient service error ended an unattended run that could otherwise have continued. This change applies the maintainer's recorded answer on #1984: option 1 with option 2's report line. - **Transient dispatch errors are retried.** On an error naming a temporary condition, the orchestrator waits on one bounded background timer of about ten minutes and then re-dispatches the same brief. The orchestrator judges the condition from the error's own text, for example an overload, a rate limit, or a check reporting a transient failure. It spends at most three waits on one dispatch. - **A dispatch that never ran is not counted as dispatched**, so a retried worker brief cannot trip the repeat stop. - **Any other failure ends the run at its own end state.** That covers a non-transient error, a dispatch still failing after the third wait, and a timer that cannot start. The final message names this end state on its own line, separate from `STOP`, with the failing seat and the last error. - The generated distributions are regenerated with `scripts/build_dist.py`. ## Verification - `python3 scripts/build_dist.py --check` passes. - `scripts/prose_lint.py --diff` passes, both alone and with `--check sentence-length`. - `spec/validate.py` passes, and the Docker linters pass. - Two `local-strict-review` passes were recorded. The first raised 9 findings: 8 long sentences, and a transient test narrower than the maintainer's answer. All 9 are fixed. The second pass raised none. Closes on promotion: #1984 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/unattended-handoff/SKILL.md | 46 +++++++++++++++---- .../.source-digests/unattended-handoff | 2 +- .../skills/unattended-handoff/SKILL.md | 46 +++++++++++++++---- .github/skills/unattended-handoff/SKILL.md | 46 +++++++++++++++---- 4 files changed, 112 insertions(+), 28 deletions(-) diff --git a/.agents/skills/unattended-handoff/SKILL.md b/.agents/skills/unattended-handoff/SKILL.md index d12ee395b..c36140580 100644 --- a/.agents/skills/unattended-handoff/SKILL.md +++ b/.agents/skills/unattended-handoff/SKILL.md @@ -34,10 +34,11 @@ no reminder. ## The Three Seats - **The orchestrator** is the session this skill is invoked in. It resolves the repository once, - then only dispatches, reads one line back, and dispatches again. It reads no file, issue, diff, - or review, writes no handoff, and saves no memory. Everything it would learn by looking belongs - to a subagent that starts empty and is discarded after one round. Its cost stays flat because of - what it holds, whatever tier it runs on. + then only dispatches, reads one line back, and dispatches again, waiting out a dispatch that + fails per "The Loop". It reads no file, issue, diff, or review, writes no handoff, and saves no + memory. Everything it would learn by looking belongs to a subagent that starts empty and is + discarded after one round. Its cost stays flat because of what it holds, whatever tier it runs + on. - **The picker** is a subagent dispatched once per round. It chooses one handoff whose work needs no maintainer decision, creating one where none is waiting, and returns its number and track. - **The worker** is a subagent dispatched once per round on the handoff the picker returned. It @@ -83,9 +84,10 @@ maintainer starts a second run on a repository only once the first has ended. ## The Loop The orchestrator first resolves `/` from the checkout's `origin`, or takes it from the -invocation, which is the one command it runs. From then on it holds exactly four things. They are -the scope and any `highest` the invocation named, the round count, the dispatched handoff numbers, -and each round's one-line outcome. +invocation. Apart from the timer "A Dispatch That Fails" names, that is the one command it runs. +From then on it holds only the scope and any `highest` the invocation named, the round count, and +the dispatched handoff numbers. It also holds each round's one-line outcome and the waits spent on +a dispatch being retried. Each round runs: 1. **Dispatch the picker** at `frontier` with the brief below, or at `highest` where the @@ -102,11 +104,37 @@ Each round runs: 4. **Record its one line** and start the next round. `STOP` ends the run, and so does a reply that is not one of the lines below, rather than being read further. -The run ends at `NONE`, `STOP`, an unlisted reply, the round cap, or the repeat stop above. Its -final message lists every round's outcome line, then the count of parked handoffs, and names the +The run ends at `NONE`, `STOP`, an unlisted reply, the round cap, the repeat stop, or a dispatch +failure per "A Dispatch That Fails". Its final message lists every round's outcome line, then the +dispatch failure where one ended the run. It then gives the count of parked handoffs, and names the attended session (`session-handoff`, "resume the handoff") as where they get answered. It writes nothing else and asks nothing. +### A Dispatch That Fails + +A dispatch can fail before its subagent starts, when the harness or a check in front of it returns +an error instead. That is a state of the session rather than of any issue. No picker or worker line +describes it, so the orchestrator handles it itself. + +- **An error naming a temporary condition is retried, where no subagent started.** The + orchestrator judges the condition from the error's own text, such as an overload, a rate limit, + or a check reporting a transient failure. A retry needs the dispatch mechanism to show that no + subagent started. A second seat on the same brief would duplicate the first one's work. + The orchestrator then waits on one bounded background timer of about ten minutes, such as a + backgrounded `sleep 600` whose exit wakes the session. It then dispatches the same brief again + unchanged. It spends at most three waits on one dispatch, and a dispatch that runs resets that + count. +- **A dispatch that never ran was not dispatched.** A handoff number joins the dispatched ones only + once its worker runs. A worker brief retried this way therefore does not meet the repeat stop in + step 2. +- **Anything else ends the run at a dispatch failure.** That covers an error naming no temporary + condition, an error leaving unclear whether a subagent started, and a dispatch still failing after + its third wait. It also covers a timer that cannot itself start, since an outage refusing every + call refuses the timer too. It is an end state of its own, apart from `STOP`, which only a + subagent returns. The final message names it in a line of its own, with the seat whose dispatch + failed and the error its last attempt returned. Where the timer could not start, that line also + carries the timer's own error. + ### The Briefs Pass these verbatim, filling the angle brackets. They name this skill rather than restating it, diff --git a/.claude-plugin/fleet-skills/.source-digests/unattended-handoff b/.claude-plugin/fleet-skills/.source-digests/unattended-handoff index 5fdeec54a..343bdd5bf 100644 --- a/.claude-plugin/fleet-skills/.source-digests/unattended-handoff +++ b/.claude-plugin/fleet-skills/.source-digests/unattended-handoff @@ -1 +1 @@ -bcb7fcc3345bb6aa +69e16d2b602fcf6c diff --git a/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md b/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md index d12ee395b..c36140580 100644 --- a/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/unattended-handoff/SKILL.md @@ -34,10 +34,11 @@ no reminder. ## The Three Seats - **The orchestrator** is the session this skill is invoked in. It resolves the repository once, - then only dispatches, reads one line back, and dispatches again. It reads no file, issue, diff, - or review, writes no handoff, and saves no memory. Everything it would learn by looking belongs - to a subagent that starts empty and is discarded after one round. Its cost stays flat because of - what it holds, whatever tier it runs on. + then only dispatches, reads one line back, and dispatches again, waiting out a dispatch that + fails per "The Loop". It reads no file, issue, diff, or review, writes no handoff, and saves no + memory. Everything it would learn by looking belongs to a subagent that starts empty and is + discarded after one round. Its cost stays flat because of what it holds, whatever tier it runs + on. - **The picker** is a subagent dispatched once per round. It chooses one handoff whose work needs no maintainer decision, creating one where none is waiting, and returns its number and track. - **The worker** is a subagent dispatched once per round on the handoff the picker returned. It @@ -83,9 +84,10 @@ maintainer starts a second run on a repository only once the first has ended. ## The Loop The orchestrator first resolves `/` from the checkout's `origin`, or takes it from the -invocation, which is the one command it runs. From then on it holds exactly four things. They are -the scope and any `highest` the invocation named, the round count, the dispatched handoff numbers, -and each round's one-line outcome. +invocation. Apart from the timer "A Dispatch That Fails" names, that is the one command it runs. +From then on it holds only the scope and any `highest` the invocation named, the round count, and +the dispatched handoff numbers. It also holds each round's one-line outcome and the waits spent on +a dispatch being retried. Each round runs: 1. **Dispatch the picker** at `frontier` with the brief below, or at `highest` where the @@ -102,11 +104,37 @@ Each round runs: 4. **Record its one line** and start the next round. `STOP` ends the run, and so does a reply that is not one of the lines below, rather than being read further. -The run ends at `NONE`, `STOP`, an unlisted reply, the round cap, or the repeat stop above. Its -final message lists every round's outcome line, then the count of parked handoffs, and names the +The run ends at `NONE`, `STOP`, an unlisted reply, the round cap, the repeat stop, or a dispatch +failure per "A Dispatch That Fails". Its final message lists every round's outcome line, then the +dispatch failure where one ended the run. It then gives the count of parked handoffs, and names the attended session (`session-handoff`, "resume the handoff") as where they get answered. It writes nothing else and asks nothing. +### A Dispatch That Fails + +A dispatch can fail before its subagent starts, when the harness or a check in front of it returns +an error instead. That is a state of the session rather than of any issue. No picker or worker line +describes it, so the orchestrator handles it itself. + +- **An error naming a temporary condition is retried, where no subagent started.** The + orchestrator judges the condition from the error's own text, such as an overload, a rate limit, + or a check reporting a transient failure. A retry needs the dispatch mechanism to show that no + subagent started. A second seat on the same brief would duplicate the first one's work. + The orchestrator then waits on one bounded background timer of about ten minutes, such as a + backgrounded `sleep 600` whose exit wakes the session. It then dispatches the same brief again + unchanged. It spends at most three waits on one dispatch, and a dispatch that runs resets that + count. +- **A dispatch that never ran was not dispatched.** A handoff number joins the dispatched ones only + once its worker runs. A worker brief retried this way therefore does not meet the repeat stop in + step 2. +- **Anything else ends the run at a dispatch failure.** That covers an error naming no temporary + condition, an error leaving unclear whether a subagent started, and a dispatch still failing after + its third wait. It also covers a timer that cannot itself start, since an outage refusing every + call refuses the timer too. It is an end state of its own, apart from `STOP`, which only a + subagent returns. The final message names it in a line of its own, with the seat whose dispatch + failed and the error its last attempt returned. Where the timer could not start, that line also + carries the timer's own error. + ### The Briefs Pass these verbatim, filling the angle brackets. They name this skill rather than restating it, diff --git a/.github/skills/unattended-handoff/SKILL.md b/.github/skills/unattended-handoff/SKILL.md index d12ee395b..c36140580 100644 --- a/.github/skills/unattended-handoff/SKILL.md +++ b/.github/skills/unattended-handoff/SKILL.md @@ -34,10 +34,11 @@ no reminder. ## The Three Seats - **The orchestrator** is the session this skill is invoked in. It resolves the repository once, - then only dispatches, reads one line back, and dispatches again. It reads no file, issue, diff, - or review, writes no handoff, and saves no memory. Everything it would learn by looking belongs - to a subagent that starts empty and is discarded after one round. Its cost stays flat because of - what it holds, whatever tier it runs on. + then only dispatches, reads one line back, and dispatches again, waiting out a dispatch that + fails per "The Loop". It reads no file, issue, diff, or review, writes no handoff, and saves no + memory. Everything it would learn by looking belongs to a subagent that starts empty and is + discarded after one round. Its cost stays flat because of what it holds, whatever tier it runs + on. - **The picker** is a subagent dispatched once per round. It chooses one handoff whose work needs no maintainer decision, creating one where none is waiting, and returns its number and track. - **The worker** is a subagent dispatched once per round on the handoff the picker returned. It @@ -83,9 +84,10 @@ maintainer starts a second run on a repository only once the first has ended. ## The Loop The orchestrator first resolves `/` from the checkout's `origin`, or takes it from the -invocation, which is the one command it runs. From then on it holds exactly four things. They are -the scope and any `highest` the invocation named, the round count, the dispatched handoff numbers, -and each round's one-line outcome. +invocation. Apart from the timer "A Dispatch That Fails" names, that is the one command it runs. +From then on it holds only the scope and any `highest` the invocation named, the round count, and +the dispatched handoff numbers. It also holds each round's one-line outcome and the waits spent on +a dispatch being retried. Each round runs: 1. **Dispatch the picker** at `frontier` with the brief below, or at `highest` where the @@ -102,11 +104,37 @@ Each round runs: 4. **Record its one line** and start the next round. `STOP` ends the run, and so does a reply that is not one of the lines below, rather than being read further. -The run ends at `NONE`, `STOP`, an unlisted reply, the round cap, or the repeat stop above. Its -final message lists every round's outcome line, then the count of parked handoffs, and names the +The run ends at `NONE`, `STOP`, an unlisted reply, the round cap, the repeat stop, or a dispatch +failure per "A Dispatch That Fails". Its final message lists every round's outcome line, then the +dispatch failure where one ended the run. It then gives the count of parked handoffs, and names the attended session (`session-handoff`, "resume the handoff") as where they get answered. It writes nothing else and asks nothing. +### A Dispatch That Fails + +A dispatch can fail before its subagent starts, when the harness or a check in front of it returns +an error instead. That is a state of the session rather than of any issue. No picker or worker line +describes it, so the orchestrator handles it itself. + +- **An error naming a temporary condition is retried, where no subagent started.** The + orchestrator judges the condition from the error's own text, such as an overload, a rate limit, + or a check reporting a transient failure. A retry needs the dispatch mechanism to show that no + subagent started. A second seat on the same brief would duplicate the first one's work. + The orchestrator then waits on one bounded background timer of about ten minutes, such as a + backgrounded `sleep 600` whose exit wakes the session. It then dispatches the same brief again + unchanged. It spends at most three waits on one dispatch, and a dispatch that runs resets that + count. +- **A dispatch that never ran was not dispatched.** A handoff number joins the dispatched ones only + once its worker runs. A worker brief retried this way therefore does not meet the repeat stop in + step 2. +- **Anything else ends the run at a dispatch failure.** That covers an error naming no temporary + condition, an error leaving unclear whether a subagent started, and a dispatch still failing after + its third wait. It also covers a timer that cannot itself start, since an outage refusing every + call refuses the timer too. It is an end state of its own, apart from `STOP`, which only a + subagent returns. The final message names it in a line of its own, with the seat whose dispatch + failed and the error its last attempt returned. Where the timer could not start, that line also + carries the timer's own error. + ### The Briefs Pass these verbatim, filling the angle brackets. They name this skill rather than restating it, From ae86a02b5aaf7add6fb08a34d5acdd4a9aa6e661 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 05:52:22 -0700 Subject: [PATCH 12/24] Name a Python File the Prose Gate Cannot Parse in Its Scope Line (#2797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A `.py` file the running interpreter cannot parse was read in silence by the gate's parser-based rules: `comment-wrap` and `comment-case` fell back to a line scan, `comment-added` and the comment half of `issue-ref` read no comment, the docstring half of `issue-ref` read no docstring, and before 3.12 the tokenizer reads a 3.12 f-string as an open string and swallows the comments after it without refusing anything. The run then printed the same scope line a clean tree prints. Newer grammar under an older interpreter reaches this through every local surface that runs the gate on a host interpreter. `check_file` now asks `ast` once, through a new cached `python_tree`, for any `.py` a rule in `PARSED_RULES` read, and the scope line names every file it refused: ```text scope: 1 file(s) read, whole tree, gate local , 1 .py file(s) Python 3.11 cannot parse, so their comments and docstrings may be misread: 'newer.py' ``` The `ast` parse is the test even for a rule that only tokenizes, because of the f-string case above. The exit code is unchanged, since a file the repository's own interpreter parses is not a defect in the file. A leading byte order mark is dropped before both the `tokenize` and the `ast` read, since `ast` refuses one in a string on every interpreter and the tokenizer reads it as code before a first-line comment. `quoted` takes an optional cap, which replaces the five-name cut the unread-diff refusal built by hand. ## Verification - Tests construct files no interpreter tokenizes or parses, so they hold under whichever interpreter runs the suite: each parser-based rule names both, a parsing file, a run with no parser-based rule, and a non-UTF-8 file are not named, the list is cut at five, the diff-scoped line carries the clause, and a byte order mark neither names the file nor hides a first-line comment or a docstring reference. - Removing the naming check, the parse gate on `PARSED_RULES`, either byte order mark drop, or the first-line column adjustment each fails at least one test. - Constructed files run under Python 3.11 and 3.13: a `type Alias = int` above a docstring naming an issue, and a 3.12 f-string above a wrapped comment. Under 3.11 each is named and the run exits 0, under 3.13 the finding is reported. - Full suite (`python -m unittest discover -s tests`) passes, and the pre-commit gate set is clean. Filed along the way: #2796, a file whose bytes are not UTF-8 is skipped in silence, and #2798, a byte order mark hides a first-line comment in the languages other than Python. Both are pre-existing. Closes on promotion: #2163 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 101 +++++++++++++++++++---- tests/test_prose_lint.py | 95 +++++++++++++++++++++ 2 files changed, 179 insertions(+), 17 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index ad22cb579..a9824c953 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -533,7 +533,12 @@ def git(*args: str) -> str | None: def scope_note( - read: int, discovered: int, lines: int | None, base: str | None, provenance: str + read: int, + discovered: int, + lines: int | None, + base: str | None, + provenance: str, + unparsed: list[str] | None = None, ) -> str: """What the run actually read, stated on every verdict rather than only on a busy one. @@ -549,13 +554,26 @@ def scope_note( The copy that read that scope is named beside it for the same reason. A scope answers what was read and a provenance answers what read it, and a verdict reproducing nowhere is a question about the second rather than about the first. + + A `.py` file the running interpreter cannot parse is named beside them too, where a rule that + reads Python through a parser ran. Such a rule reads that file by a line scan, not at all, or + through a tokenizer older than its grammar, so a pass under an interpreter older than the code + it reads is otherwise the same line as a clean one. """ if base is None: - return f"scope: {read} file(s) read, whole tree, gate {provenance}" - return ( - f"scope: {read} of {discovered} file(s) read, {lines} changed line(s), " - f"diff against {base!r}, gate {provenance}" - ) + note = f"scope: {read} file(s) read, whole tree, gate {provenance}" + else: + note = ( + f"scope: {read} of {discovered} file(s) read, {lines} changed line(s), " + f"diff against {base!r}, gate {provenance}" + ) + if unparsed: + version = f"{sys.version_info.major}.{sys.version_info.minor}" + note += ( + f", {len(unparsed)} .py file(s) Python {version} cannot parse, " + f"so their comments and docstrings may be misread: {quoted(unparsed, 5)}" + ) + return note def home_path_findings(lineno: int, line: str) -> list[tuple[int, str, str]]: @@ -854,13 +872,17 @@ def comment_syntax_including_docs(path: Path) -> Syntax | None: return widened -def quoted(paths) -> str: +def quoted(paths, cap: int | None = None) -> str: """Paths as a sorted, quoted, comma-joined list for an error message. Quoted because a path holding a space or a comma is indistinguishable from two paths once - joined, which makes the message unreadable exactly when it names something unexpected. + joined, which makes the message unreadable exactly when it names something unexpected. With a + `cap`, only the first that many are listed, followed by a marker where the list ran longer. """ - return ", ".join(repr(str(p)) for p in sorted(paths)) + ordered = sorted(paths) + shown = ordered if cap is None else ordered[:cap] + more = " and more" if len(shown) < len(ordered) else "" + return ", ".join(repr(str(p)) for p in shown) + more def repo_root(path: Path) -> str: @@ -2003,11 +2025,13 @@ def python_comments(raw: str) -> list[Comment] | None: """Every comment in Python source, or None if it will not parse. `tokenize` rather than a regex because a `#` inside a string literal is not a comment, and a - trailing comment is one a line-anchored pattern never sees. + trailing comment is one a line-anchored pattern never sees. A leading byte order mark is + dropped first, since it is not whitespace and would read a first-line comment as trailing. """ out: list[Comment] = [] try: - for tok in tokenize.generate_tokens(io.StringIO(raw).readline): + source = io.StringIO(raw.removeprefix("\ufeff")) + for tok in tokenize.generate_tokens(source.readline): if tok.type == tokenize.COMMENT: col = tok.start[1] leading = not tok.line[:col].strip() @@ -2411,6 +2435,19 @@ def comment_added_findings(path: Path, raw: str) -> list[tuple[int, str, str]]: return out +@functools.lru_cache(maxsize=1) +def python_tree(raw: str) -> ast.Module | None: + """The parsed module, or None where this interpreter cannot parse the source. + + A leading byte order mark is dropped first, since `ast` refuses one in a string on every + interpreter. Cached for the one file in hand, which `check_file` and `issue-ref` both parse. + """ + try: + return ast.parse(raw.removeprefix("\ufeff")) + except (SyntaxError, ValueError): + return None + + def python_docstring_spans(raw: str, lines: list[str]) -> dict[int, list[str]]: """Each source line a docstring occupies, mapped to the docstring text on that line. @@ -2422,11 +2459,15 @@ def python_docstring_spans(raw: str, lines: list[str]) -> dict[int, list[str]]: the line that carries it. A value has its escapes resolved, which no longer maps to a line. Sliced at the node's own columns rather than taken as whole lines, since a statement can share a line with the closing quote and reading it would report a string literal this rule excludes. + + `python_tree` drops a leading byte order mark, so it is dropped from the first line too, which + keeps that line's columns in step with the parse. """ - try: - tree = ast.parse(raw) - except (SyntaxError, ValueError): + tree = python_tree(raw) + if tree is None: return {} + if raw.startswith("\ufeff"): + lines = [lines[0].removeprefix("\ufeff"), *lines[1:]] out: dict[int, list[str]] = {} for node in ast.walk(tree): if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): @@ -2561,12 +2602,23 @@ def issue_ref_findings( return out +PARSED_RULES = frozenset({"comment-wrap", "comment-case", "comment-added", "issue-ref"}) + + def check_file( path: Path, rules: set[str], root: Path | None = None, allowed: set[int] | None = None, + unparsed: list[Path] | None = None, ) -> list[tuple[int, str, str]]: + """Every finding the rules in `rules` make on `path`. + + `unparsed` collects a `.py` this interpreter cannot parse where a rule in `PARSED_RULES` ran. + The parse is the test even for a rule that only tokenizes, since a tokenizer older than the + grammar can read a newer f-string as an open string and swallow the comments after it without + refusing anything. + """ out: list[tuple[int, str, str]] = [] try: raw = path.read_bytes().decode("utf-8") @@ -2587,6 +2639,13 @@ def check_file( out.extend(comment_added_findings(path, raw)) if "issue-ref" in rules: out.extend(issue_ref_findings(path, raw, lines, root)) + if ( + unparsed is not None + and PARSED_RULES & rules + and path.suffix.lower() == ".py" + and python_tree(raw) is None + ): + unparsed.append(path) # Outside Markdown the prose lives in the comments, and both rules judge prose, not code. # A source line holds identifiers and literals, and an attribute value may legally repeat. # Reading it rejects correct work, `class="gallery gallery-cols-1"` being the reported case. @@ -2877,7 +2936,7 @@ def main(argv: list[str] | None = None) -> int: if scope and not matched: unread = unread_diff_files(scope, a.paths or ["."], tuple(a.exclude), scan_root) if unread: - shown = ", ".join(unread[:5]) + (" and more" if len(unread) > 5 else "") + shown = quoted(unread, 5) print( f"error: the diff against {a.diff!r} names {len(unread)} readable file(s) " f"this run was asked about, and the scan matched none of them: {shown}. " @@ -2893,9 +2952,10 @@ def main(argv: list[str] | None = None) -> int: total = 0 bykind: dict[str, int] = {} byfile: dict[str, int] = {} + refused: list[Path] = [] for f in files: allowed = scope.get(keys[f]) if scope is not None else None - for ln, kind, msg in check_file(f, rules, scan_root, allowed): + for ln, kind, msg in check_file(f, rules, scan_root, allowed, refused): if allowed is not None and ln not in allowed: continue total += 1 @@ -2906,7 +2966,14 @@ def main(argv: list[str] | None = None) -> int: inscope = sum(len(scope[keys[f]]) for f in files) if scope is not None else None print( - scope_note(len(files), discovered, inscope, a.diff, gate_provenance(a.provenance)), + scope_note( + len(files), + discovered, + inscope, + a.diff, + gate_provenance(a.provenance), + [keys[f] for f in refused], + ), file=sys.stderr, ) if a.summary or total: diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 1a7499427..d9be466bd 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2710,6 +2710,101 @@ def test_a_bare_run_checks_comment_shape(self) -> None: with mock.patch.object(prose_lint, "discover", return_value=[bait]): self.assertEqual(1, prose_lint.main([])) + UNTOKENIZED = 'def f():\n """A docstring the gate reads."""\n return (1,\n' + UNPARSED = 'def f():\n """A docstring the gate reads."""\n return = 1\n' + + def scope_line(self, paths: list[Path], argv: list[str]) -> str: + with ( + mock.patch.object(prose_lint, "discover", return_value=paths), + contextlib.redirect_stderr(io.StringIO()) as err, + ): + self.assertEqual(0, prose_lint.main(argv)) + return err.getvalue() + + def bait(self, name: str, text: str) -> Path: + path = self.tmp / name + path.write_text(text, encoding="utf-8") + return path + + def test_a_python_file_the_interpreter_cannot_parse_is_named_by_every_parser_rule( + self, + ) -> None: + """A file the parser-based rules may have misread is not a clean pass, so the line says so. + + On a file `tokenize` refuses, `comment-wrap` and `comment-case` fall back to a line scan, + and `comment-added` and `issue-ref` read no comment. On one it accepts and `ast` refuses, + `issue-ref` reads no docstring, and a tokenizer older than the grammar can read a newer + f-string as an open string and swallow the comments after it. Either way the run exited 0 + with the scope line a clean tree prints. Neither fixture parses under any interpreter, so + the case holds whichever one runs the suite. `comment-added` runs only under `--diff`, so + its case is the diff-scoped one. + """ + for name, text in (("open.py", self.UNTOKENIZED), ("newer.py", self.UNPARSED)): + bait = self.bait(name, text) + for rule in ("comment-wrap", "comment-case", "issue-ref"): + with self.subTest(file=name, rule=rule): + err = self.scope_line([bait], ["--check", rule]) + self.assertIn("1 .py file(s) Python", err) + self.assertIn(f"may be misread: {bait.as_posix()!r}", err) + + def test_a_python_file_that_parses_is_not_named(self) -> None: + clean = self.bait("clean.py", 'def f():\n """A docstring the gate reads."""\n') + self.assertNotIn("may be misread", self.scope_line([clean], [])) + + def test_an_unparsed_file_is_not_named_when_no_parser_based_rule_ran(self) -> None: + """A run reading the file only line by line lost nothing to the parse, so names nothing.""" + bait = self.bait("open.py", self.UNTOKENIZED) + self.assertNotIn("may be misread", self.scope_line([bait], ["--check", "dupword"])) + + def test_a_python_file_that_is_not_utf8_is_not_blamed_on_the_interpreter(self) -> None: + """No rule reads a file the gate cannot decode, so no parse was refused on it.""" + latin = self.tmp / "latin.py" + latin.write_bytes(b"# caf\xe9 is fine.\nx = 1\n") + self.assertNotIn("may be misread", self.scope_line([latin], [])) + + def test_a_byte_order_mark_is_not_read_as_an_unparsed_file(self) -> None: + """An editor-added mark parses on every interpreter, so its docstrings are still read. + + `ast` refuses the mark in a string, which named the file as one the interpreter could not + parse and skipped the reference its module docstring holds. The reference closes the + docstring, so a first line sliced at columns that still count the mark loses its digits. + """ + marked = self.bait("marked.py", "\ufeff'See #12'\n") + with ( + mock.patch.object(prose_lint, "discover", return_value=[marked]), + contextlib.redirect_stdout(io.StringIO()) as out, + contextlib.redirect_stderr(io.StringIO()) as err, + ): + self.assertEqual(1, prose_lint.main(["--check", "issue-ref"])) + self.assertIn( + "marked.py:1: issue-ref: issue or pull request reference '#12'", out.getvalue() + ) + self.assertNotIn("may be misread", err.getvalue()) + + def test_a_byte_order_mark_leaves_a_first_line_comment_leading(self) -> None: + """The mark is not whitespace, so read as code it made a first-line comment trailing.""" + comments = prose_lint.python_comments("\ufeff# See the reason.\nx = 1\n") + self.assertEqual([(1, "See the reason.", True, "# See the reason.")], comments) + + def test_a_long_unparsed_list_is_cut_to_five_names(self) -> None: + baits = [self.bait(f"open{n}.py", self.UNTOKENIZED) for n in range(6)] + err = self.scope_line(baits, ["--check", "comment-wrap"]) + self.assertIn("6 .py file(s) Python", err) + self.assertIn(f"{baits[4].as_posix()!r} and more", err) + self.assertNotIn(baits[5].as_posix(), err) + + def test_the_diff_scoped_line_names_an_unparsed_file_too(self) -> None: + """The environment override is cleared, since it stands `comment-added` down.""" + bait = self.bait("open.py", self.UNTOKENIZED) + with ( + mock.patch.dict(os.environ, {"PROSE_ALLOW_COMMENTS": ""}), + mock.patch.object(prose_lint, "repo_root", return_value=str(self.tmp)), + mock.patch.object(prose_lint, "changed_lines", return_value={"open.py": {1}}), + ): + err = self.scope_line([bait], ["--check", "comment-added", "--diff", "HEAD"]) + self.assertIn("1 changed line(s)", err) + self.assertIn("may be misread: 'open.py'", err) + def test_a_path_under_no_repository_is_refused_rather_than_scoped_to_nothing(self) -> None: """A diff needs a repository, and having none is an error rather than an empty scope. From 58c081c7a9337025b62518a8c25b9adb8e6035ad Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 06:19:51 -0700 Subject: [PATCH 13/24] Read a First-Line Comment After a Byte Order Mark as Leading in Every Language (#2800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A file that starts with a UTF-8 byte order mark had its first-line comment read as trailing by the prose gate in every language other than Python. `extracted_comments` judges a comment leading by `not line[:at].strip()`, and U+FEFF is not whitespace, so `comment-added` and `issue-ref`, which read leading comments only, dropped it. The mark was already being removed per reader, at three Python sites, and readers nobody patched kept it. A Markdown file opening with a marked code fence, for one, had its fence state inverted for the whole file. So `check_file` now drops the mark once, right after decoding, and the per-reader removals go away. The character-set rule reports U+FEFF, so it is given the mark on its own, where a fence opening line 1 cannot hide it. ## Tests - One case per comment syntax the gate reads, through `check_file` and `issue-ref`: `#` in Python, shell, YAML, TOML, HCL, PowerShell, and KiCad files, `//` and `/* */` in C-like files, `/* */` in CSS, ``, `<# #>`, and `;`. Each fails with the removal reverted. - A Markdown file whose marked first line opens a fence reads the fence correctly. - The character-set rule still reports the mark exactly once, including when line 1 opens a fence, and fails if that report is removed. Closes on promotion: #2798 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Improved prose checks for files with a leading byte-order mark. First-line comments and code comments are now checked more consistently, and enabled charset rules report the mark as a line-one finding. * Updated checks for issue references, added comments, semicolons in Markdown code fences, and unknown charset characters. --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 24 +++++------ tests/test_prose_lint.py | 55 ++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index a9824c953..07b5b69ea 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -2025,12 +2025,11 @@ def python_comments(raw: str) -> list[Comment] | None: """Every comment in Python source, or None if it will not parse. `tokenize` rather than a regex because a `#` inside a string literal is not a comment, and a - trailing comment is one a line-anchored pattern never sees. A leading byte order mark is - dropped first, since it is not whitespace and would read a first-line comment as trailing. + trailing comment is one a line-anchored pattern never sees. """ out: list[Comment] = [] try: - source = io.StringIO(raw.removeprefix("\ufeff")) + source = io.StringIO(raw) for tok in tokenize.generate_tokens(source.readline): if tok.type == tokenize.COMMENT: col = tok.start[1] @@ -2439,11 +2438,10 @@ def comment_added_findings(path: Path, raw: str) -> list[tuple[int, str, str]]: def python_tree(raw: str) -> ast.Module | None: """The parsed module, or None where this interpreter cannot parse the source. - A leading byte order mark is dropped first, since `ast` refuses one in a string on every - interpreter. Cached for the one file in hand, which `check_file` and `issue-ref` both parse. + Cached for the one file in hand, which `check_file` and `issue-ref` both parse. """ try: - return ast.parse(raw.removeprefix("\ufeff")) + return ast.parse(raw) except (SyntaxError, ValueError): return None @@ -2459,15 +2457,10 @@ def python_docstring_spans(raw: str, lines: list[str]) -> dict[int, list[str]]: the line that carries it. A value has its escapes resolved, which no longer maps to a line. Sliced at the node's own columns rather than taken as whole lines, since a statement can share a line with the closing quote and reading it would report a string literal this rule excludes. - - `python_tree` drops a leading byte order mark, so it is dropped from the first line too, which - keeps that line's columns in step with the parse. """ tree = python_tree(raw) if tree is None: return {} - if raw.startswith("\ufeff"): - lines = [lines[0].removeprefix("\ufeff"), *lines[1:]] out: dict[int, list[str]] = {} for node in ast.walk(tree): if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): @@ -2618,12 +2611,19 @@ def check_file( The parse is the test even for a rule that only tokenizes, since a tokenizer older than the grammar can read a newer f-string as an open string and swallow the comments after it without refusing anything. + + A leading byte order mark is dropped once, here, since it is not whitespace and every reader + that judges line 1's structure would read it as content. The character-set rule is given the + mark on its own, where a fence opening line 1 cannot hide it. """ out: list[tuple[int, str, str]] = [] try: - raw = path.read_bytes().decode("utf-8") + decoded = path.read_bytes().decode("utf-8") except (UnicodeDecodeError, OSError): return out + raw = decoded.removeprefix("\ufeff") + if raw != decoded and {"charset", "charset-unknown"} & rules: + out.extend(f for f in charset_findings(1, "\ufeff") if f[1] in rules) lines = raw.split("\n") # Only Markdown describes what the repo holds, so only Markdown is judged for dead paths. # The file's own directory anchors a relative link, the repository root anchors the rest. diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index d9be466bd..d775e33b4 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2781,10 +2781,57 @@ def test_a_byte_order_mark_is_not_read_as_an_unparsed_file(self) -> None: ) self.assertNotIn("may be misread", err.getvalue()) - def test_a_byte_order_mark_leaves_a_first_line_comment_leading(self) -> None: - """The mark is not whitespace, so read as code it made a first-line comment trailing.""" - comments = prose_lint.python_comments("\ufeff# See the reason.\nx = 1\n") - self.assertEqual([(1, "See the reason.", True, "# See the reason.")], comments) + def test_a_byte_order_mark_leaves_a_first_line_comment_leading_in_every_syntax(self) -> None: + """The mark is not whitespace, so read as code it made a first-line comment trailing. + + `issue-ref` reads a comment only where its marker opens the line, so a trailing one hid the + reference. + """ + for name, written in ( + ("a.py", "# See #12 for why."), + ("a.sh", "# See #12 for why."), + ("a.yml", "# See #12 for why."), + ("a.toml", "# See #12 for why."), + ("a.tf", "# See #12 for why."), + ("a.cs", "// See #12 for why."), + ("a.c", "/* See #12 for why. */"), + ("a.css", "/* See #12 for why. */"), + ("a.html", ""), + ("a.ps1", "# See #12 for why."), + ("a.ps1", "<# See #12 for why. #>"), + ("a.ini", "; See #12 for why."), + ("a.kicad_sch", "# See #12 for why."), + ): + with self.subTest(file=name, comment=written): + marked = self.bait(name, f"\ufeff{written}\nx\n") + found = prose_lint.check_file(marked, {"issue-ref"}) + self.assertEqual([(1, "issue-ref")], [(line, rule) for line, rule, _ in found]) + + def test_a_byte_order_mark_leaves_a_first_line_comment_counted_as_added(self) -> None: + """`comment-added` reads leading comments only, so a trailing one went uncounted.""" + for name, written in ( + ("a.py", "# See the reason."), + ("a.sh", "# See the reason."), + ("a.cs", "// See the reason."), + ): + with self.subTest(file=name): + marked = self.bait(name, f"\ufeff{written}\nx\n") + found = prose_lint.check_file(marked, {"comment-added"}) + self.assertEqual([(1, "comment-added")], [(line, rule) for line, rule, _ in found]) + + def test_a_byte_order_mark_leaves_a_first_line_fence_opening(self) -> None: + """Read as text before the fence, the mark inverted which lines the fence held.""" + marked = self.bait("a.md", "\ufeff```sh\nx; y\n```\n\nOne; two.\n") + found = prose_lint.check_file(marked, {"semicolon"}) + self.assertEqual([5], [line for line, _rule, _message in found]) + + def test_the_character_set_rule_still_reads_a_byte_order_mark(self) -> None: + """The structural readers drop the mark, and the character-set rule still reports it.""" + for name, text in (("a.ps1", "# See the reason.\n"), ("a.md", "```sh\nx\n```\n")): + with self.subTest(file=name): + marked = self.bait(name, f"\ufeff{text}") + found = prose_lint.check_file(marked, {"charset-unknown"}) + self.assertEqual([1], [line for line, _, message in found if "U+FEFF" in message]) def test_a_long_unparsed_list_is_cut_to_five_names(self) -> None: baits = [self.bait(f"open{n}.py", self.UNTOKENIZED) for n in range(6)] From e214c35dd5641698c155c3fd42c78e9a403217c0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 06:38:52 -0700 Subject: [PATCH 14/24] Name a File the Prose Gate Cannot Decode in Its Scope Line (#2802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The prose gate read no line of a file whose bytes are not UTF-8, and said nothing about it. `check_file` caught the decode error and returned no findings, so every rule skipped the file while the run printed the same scope line a tree of readable files prints. `check_file` now collects such a file in a new `undecoded` list, whatever rules ran, together with a file whose read fails after discovery, since that is skipped the same way. `main` passes the list to `scope_note`, which names the files on the scope line the way #2797 names a Python file the interpreter cannot parse, capped at five names. The exit code is unchanged, per the issue's done criterion, so a run that skipped a file is now distinguishable from a clean one without failing a tree that legitimately holds one. A whole-tree run over this repository names no file, so the change adds nothing to the hub's own verdict. ## Tests - `test_a_file_the_gate_cannot_decode_is_named_whatever_rule_ran` builds the issue's constructed `latin.py`, and the same bytes as `latin.md`, and asserts the scope line names it under a bare run, `dupword`, and `charset`. - `test_the_diff_scoped_line_names_an_undecoded_file_too` covers the `--diff` form. - `test_a_file_that_cannot_be_opened_is_collected_as_undecoded` covers the read-failure path. - `test_a_file_that_decodes_is_not_named_as_undecoded` guards the clean case. With the `prose_lint.py` change reverted, the first three fail (7 failures and 1 error across subtests), and with it the full `tests/test_prose_lint.py` suite passes. ## Local review Two recorded `local-strict-review` passes. The first raised three findings. Two were fixed in the second commit: the `scope_note` docstring now states its cause the right way round, and the existing not-UTF-8 test now uses the shared `LATIN` fixture rather than a second copy of its bytes. The third, folding `unparsed` and `undecoded` into one reason-keyed collector, is declined on evidence. The two collectors fill under different conditions at the append site (`unparsed` only for a `.py` whose parse fails where a `PARSED_RULES` rule ran, `undecoded` on any read failure whatever ran) and print different clauses, so a keyed collector removes no per-reason code and swaps two typed parameters for string keys. The second pass raised no findings and accepted the decline. Closes on promotion: #2796 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 19 ++++++++- tests/test_prose_lint.py | 49 +++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 07b5b69ea..30e8fdbce 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -539,6 +539,7 @@ def scope_note( base: str | None, provenance: str, unparsed: list[str] | None = None, + undecoded: list[str] | None = None, ) -> str: """What the run actually read, stated on every verdict rather than only on a busy one. @@ -559,6 +560,9 @@ def scope_note( reads Python through a parser ran. Such a rule reads that file by a line scan, not at all, or through a tokenizer older than its grammar, so a pass under an interpreter older than the code it reads is otherwise the same line as a clean one. + + A file the gate cannot read as UTF-8 is named the same way. No rule reads it, yet it is counted + among the files read, so the line names it. """ if base is None: note = f"scope: {read} file(s) read, whole tree, gate {provenance}" @@ -573,6 +577,11 @@ def scope_note( f", {len(unparsed)} .py file(s) Python {version} cannot parse, " f"so their comments and docstrings may be misread: {quoted(unparsed, 5)}" ) + if undecoded: + note += ( + f", {len(undecoded)} file(s) the gate could not open or decode as UTF-8, so no rule read them: " + f"{quoted(undecoded, 5)}" + ) return note @@ -2604,6 +2613,7 @@ def check_file( root: Path | None = None, allowed: set[int] | None = None, unparsed: list[Path] | None = None, + undecoded: list[Path] | None = None, ) -> list[tuple[int, str, str]]: """Every finding the rules in `rules` make on `path`. @@ -2612,6 +2622,9 @@ def check_file( grammar can read a newer f-string as an open string and swallow the comments after it without refusing anything. + `undecoded` collects a file this function cannot read as UTF-8, whatever rules ran, since no + rule reads it and the file is otherwise counted as read with nothing found. + A leading byte order mark is dropped once, here, since it is not whitespace and every reader that judges line 1's structure would read it as content. The character-set rule is given the mark on its own, where a fence opening line 1 cannot hide it. @@ -2620,6 +2633,8 @@ def check_file( try: decoded = path.read_bytes().decode("utf-8") except (UnicodeDecodeError, OSError): + if undecoded is not None: + undecoded.append(path) return out raw = decoded.removeprefix("\ufeff") if raw != decoded and {"charset", "charset-unknown"} & rules: @@ -2953,9 +2968,10 @@ def main(argv: list[str] | None = None) -> int: bykind: dict[str, int] = {} byfile: dict[str, int] = {} refused: list[Path] = [] + undecoded: list[Path] = [] for f in files: allowed = scope.get(keys[f]) if scope is not None else None - for ln, kind, msg in check_file(f, rules, scan_root, allowed, refused): + for ln, kind, msg in check_file(f, rules, scan_root, allowed, refused, undecoded): if allowed is not None and ln not in allowed: continue total += 1 @@ -2973,6 +2989,7 @@ def main(argv: list[str] | None = None) -> int: a.diff, gate_provenance(a.provenance), [keys[f] for f in refused], + [keys[f] for f in undecoded], ), file=sys.stderr, ) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index d775e33b4..3c85b374d 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -2756,12 +2756,59 @@ def test_an_unparsed_file_is_not_named_when_no_parser_based_rule_ran(self) -> No bait = self.bait("open.py", self.UNTOKENIZED) self.assertNotIn("may be misread", self.scope_line([bait], ["--check", "dupword"])) + LATIN = b"# caf\xe9 is fine.\nx = 1\n" + def test_a_python_file_that_is_not_utf8_is_not_blamed_on_the_interpreter(self) -> None: """No rule reads a file the gate cannot decode, so no parse was refused on it.""" latin = self.tmp / "latin.py" - latin.write_bytes(b"# caf\xe9 is fine.\nx = 1\n") + latin.write_bytes(self.LATIN) self.assertNotIn("may be misread", self.scope_line([latin], [])) + def test_a_file_the_gate_cannot_decode_is_named_whatever_rule_ran(self) -> None: + """No rule reads a file that is not UTF-8, so a run that skipped it is not a clean one. + + Every rule skipped it and the run printed the scope line a tree of readable files prints. + """ + for name in ("latin.py", "latin.md"): + latin = self.tmp / name + latin.write_bytes(self.LATIN) + for argv in ([], ["--check", "dupword"], ["--check", "charset"]): + with self.subTest(file=name, argv=argv): + err = self.scope_line([latin], argv) + self.assertIn( + f"1 file(s) the gate could not open or decode as UTF-8, so no rule read them: " + f"{latin.as_posix()!r}", + err, + ) + + def test_a_file_that_decodes_is_not_named_as_undecoded(self) -> None: + clean = self.bait("clean.md", "Caf\u00e9 is fine.\n") + self.assertNotIn("could not open or decode", self.scope_line([clean], [])) + + def test_a_file_that_cannot_be_opened_is_named_too(self) -> None: + """A read failing after discovery skips the file the same way a failed decode does. + + The clause names both causes, so an open failure is not reported as an encoding one. + """ + absent = self.tmp / "absent.md" + err = self.scope_line([absent], ["--check", "dupword"]) + self.assertIn( + f"1 file(s) the gate could not open or decode as UTF-8, so no rule read them: " + f"{absent.as_posix()!r}", + err, + ) + + def test_the_diff_scoped_line_names_an_undecoded_file_too(self) -> None: + latin = self.tmp / "latin.md" + latin.write_bytes(self.LATIN) + with ( + mock.patch.object(prose_lint, "repo_root", return_value=str(self.tmp)), + mock.patch.object(prose_lint, "changed_lines", return_value={"latin.md": {1}}), + ): + err = self.scope_line([latin], ["--check", "dupword", "--diff", "HEAD"]) + self.assertIn("1 changed line(s)", err) + self.assertIn("could not open or decode as UTF-8, so no rule read them: 'latin.md'", err) + def test_a_byte_order_mark_is_not_read_as_an_unparsed_file(self) -> None: """An editor-added mark parses on every interpreter, so its docstrings are still read. From cb4abd794e2780583f5704fa9983ad2e43364d46 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 08:10:27 -0700 Subject: [PATCH 15/24] Read a Tool Name as a Prose Gate Directive Only Where It Opens the Comment (#2808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `NOT_PROSE` in the prose gate was an unanchored alternation read with `.search`, so a comment that merely named a tool (`prettier`, `eslint`, `Copyright`, `shellcheck`, `noqa`, and the rest) anywhere in a sentence was exempt from `comment-wrap`, `comment-case`, and `comment-added` together. Every name is now anchored at the body's start, as `TOOL_DIRECTIVE` already is, and each word-like name ends at a word boundary, so `noqable` or `eslintrc` opening a sentence is prose. - `noqa` and `type: ignore` may still trail prose behind their own `#`, and `pylint:` anywhere in the comment, the forms flake8 and ruff, pyright, and pylint read. - flake8's file-level exemption is matched in every spelling flake8 reads, `flake8:` or `flake8=` in any letter case, since anchoring dropped it from the old `noqa`-anywhere match. - `comment_wrap_findings` now matches the `.py` suffix case-insensitively, as `comment_bodies` already did. - The narrowed exemption newly reports one tracked line, a two-sentence comment in `.markdownlint-cli2.jsonc`, which is split here. The rest of the tracked tree is clean under the whole-tree run. - The `TOOL_DIRECTIVE` comment now states which comment rules each directive list exempts a comment from, in place of two lines describing a hazard the anchoring removed. ## Verification - `TestANamedToolIsStillProse` pins the three constructed comments from #1659, directives opening the body (ESLint's `-- description`, a ruff noqa reason, and each flake8 spelling among them), directives trailing prose, near-match words, the version anchor, and an uppercase `.PY` suffix. - Each part was mutation-checked: reverting the fix, dropping each trailing alternative, dropping either word boundary, unanchoring the version, and narrowing the flake8 spelling each fail at least one case. - Full suite `python3 -m unittest discover -s tests` passes, along with ruff, mypy, the whole-tree prose gate, the EOL gate, and `spec/validate.py`. ## Local-review findings and their outcomes - `# flake8: noqa` newly reported: fixed in 2e9e93b8, and its `=` and mixed-case spellings in 102a12ed. - A trailing `# type: ignore` judged as prose although pyright reads it: fixed in 2e9e93b8. - No case pinning the version anchor: fixed in 2e9e93b8. - A pylint pragma trailing prose without its own `#`: fixed in 9f5acddd. - A header such as `# (c) Copyright 2024 Example Corp` now draws `comment-added`, where the merge base exempted any comment containing `Copyright`: no change. #1659 requires `Copyright` mid-sentence to be prose, and `comment-added` is the one rule it reaches, which the `comments` label answers. ## Filed along the way - #2804: the uppercase Markdown suffix is read as Markdown in one place and not in the others. - #2805: a comment opening on a tool's name stays exempt though it continues as prose. A sentence guard was tried on this branch and regressed real directive forms, which that issue records. - #2806: `comment_wrap_findings` duplicates `comment_bodies`' Python dispatch with a different fallback. - #2807: a trailing `noqa` or `pylint` directive exempts the prose in front of it, and applies outside Python as well. - #2809: `noqa` and `cSpell` directives are read in one letter case though their tools read any. The change edits comment lines in code and config, so it carries the `comments` label. Closes on promotion: #1659 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Prose comments that mention tool names or directive markers are now checked consistently, while supported directives retain their intended exemptions. * Python comment handling now applies consistently to files with uppercase or lowercase extensions. * **Documentation** * Clarified that the shared ignore list applies fleet-wide and that repositories can configure local exclusions alongside their content. --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 14 ++-- .markdownlint-cli2.jsonc | 3 +- tests/test_prose_lint.py | 83 ++++++++++++++++++++++-- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 30e8fdbce..e19f0f5a8 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1565,10 +1565,13 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # A pragma, shebang, or divider is machinery rather than prose. +# A tool's name is anchored at the body's start, since a comment naming one mid-sentence is prose. +# `noqa` and `type: ignore` may also trail prose behind their own `#`, and `pylint:` anywhere. NOT_PROSE = re.compile( - r"^(!|\s*[-=#*/<>]+\s*$)|noqa|type:\s*ignore|pylint|ruff:|mypy:|shellcheck" - r"|cSpell|markdownlint|omit from toc|prettier|eslint|SPDX|Copyright" - r"|^v\d+(\.\d+)*$" + r"^(?:!|\s*[-=#*/<>]+\s*$|ruff:|mypy:|(?i:flake8)[:=]|v\d+(?:\.\d+)*$" + r"|(?:noqa|type:\s*ignore|pylint|shellcheck|cSpell|markdownlint|omit from toc|prettier|eslint" + r"|SPDX|Copyright)\b)" + r"|#\s*(?:noqa|type:\s*ignore)\b|\bpylint:" ) # A comment that is only a URI is a reference, not a sentence, so neither case nor wrap applies. @@ -1665,8 +1668,7 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # A directive a tool reads, exempt because deleting one changes what the file does. # Anchored at the body's start, since a comment that merely names one of these is prose. # Read by `comment-added` alone rather than added to `NOT_PROSE`, which two gating rules share. -# Widening that pattern stopped those two reporting any comment naming a word in it. -# That is a gate narrowing where the intent was an exemption in a third rule. +# So a name here is exempt from that one rule, and a name in `NOT_PROSE` from all three. TOOL_DIRECTIVE = re.compile( r"^(?:syntax=|escape=|fmt:\s*(?:on|off|skip)\b|pragma:|nosec\b|checkov:|hadolint\b" r"|renovate:|yaml-language-server:|-\*-\s*coding[:=]|pyright:|isort:|nopep8\b" @@ -2260,7 +2262,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ The rule is one sentence per comment line. A wrapped sentence is the common failure, and a run-on is the other half of the same rule, so both are reported. """ - comments = python_comments(raw) if path.suffix == ".py" else None + comments = python_comments(raw) if path.suffix.lower() == ".py" else None if comments is None: comments = extracted_comments(path, lines) skip = fenced_lines(lines) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index e43317769..4ffe8222b 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -20,7 +20,8 @@ "globs": ["**/*.md"], // Third-party Markdown under node_modules is not authored prose, and a repository's .gitignore does not reliably exclude it. // A bare `node_modules` entry would match the root copy only. - // This list is fleet-wide. A repository excluding a subtree of its own adds no entry here, and puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content instead, which a bare local run and CI both honor. + // This list is fleet-wide. + // A repository excluding a subtree of its own adds no entry here, and puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content instead, which a bare local run and CI both honor. // Those `ignores` patterns resolve against the directory holding that file rather than against the repository root, so each entry names its target as seen from that directory and a repository-root path matches nothing there, reporting no error saying so. "ignores": ["**/node_modules/**"] } diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 3c85b374d..24ab4ba39 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -1532,6 +1532,83 @@ def test_the_syntax_table_covers_a_plausible_number_of_extensions(self) -> None: self.assertIsNotNone(prose_lint.syntax_for(Path(f"x{label}"))) +class TestANamedToolIsStillProse(BaitCase): + """A comment that names a tool is prose about it, and only a directive the tool reads is exempt. + + The exemption was searched anywhere in the body, so writing a tool's name into a sentence + disarmed every comment rule for that line, `comment-added` included. + """ + + COMMENT_RULES: ClassVar[set[str]] = {"comment-wrap", "comment-case", "comment-added"} + + def test_a_sentence_naming_a_tool_is_judged_as_prose(self) -> None: + for text, expected in ( + ("# The parser cannot read what prettier writes here.\n", ["comment-added"]), + ("# The header below states the Copyright the vendor requires.\n", ["comment-added"]), + ( + "# two sentences here. Here is another one about eslint.\n", + ["comment-wrap", "comment-case", "comment-added"], + ), + ("# The noqa marker here is read per line.\n", ["comment-added"]), + ("# Each type: ignore here names its code.\n", ["comment-added"]), + ("# Bump to v2.3\n", ["comment-added"]), + # A name has to end where the directive's word does, so a longer word opening prose is prose. + ( + "# noqable starts here. Another sentence.\n", + ["comment-wrap", "comment-case", "comment-added"], + ), + ("# eslintrc is read from the root.\n", ["comment-case", "comment-added"]), + ): + with self.subTest(text=text.strip()): + self.assertEqual(expected, self.kinds(text, self.COMMENT_RULES, name="tool.py")) + + def test_a_directive_opening_the_body_is_still_exempt(self) -> None: + for name, text in ( + ("tool.py", "# Copyright 2024 Example Corp.\n"), + ("tool.py", "# SPDX-License-Identifier: MIT\n"), + ("tool.py", "# pylint: disable=too-many-branches\n"), + ("tool.py", "# noqa: S603 - fixed argv\n"), + ("tool.py", "# type: ignore[import-untyped]\n"), + ("run.sh", "# shellcheck disable=SC2086\n"), + ("run.sh", "# shellcheck disable=SC1091 # A host file, not present at lint time.\n"), + ("app.ts", "// eslint-disable-next-line no-console -- Logging is intended here.\n"), + ("tool.py", "# ruff: noqa: E501 - the URLs cannot wrap.\n"), + ("tool.py", "# flake8: noqa\n"), + ("tool.py", "# flake8: noqa: E501\n"), + ("tool.py", "# flake8=noqa\n"), + ("tool.py", "# Flake8: noqa\n"), + ("tool.py", "# v2.3\n"), + ("app.ts", "// eslint-disable-next-line no-console\n"), + ("app.ts", "// prettier-ignore\n"), + ("settings.jsonc", "// cSpell:ignore exampleword\n"), + ): + with self.subTest(name=name, text=text.strip()): + self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) + + def test_a_directive_behind_its_own_marker_may_trail_prose(self) -> None: + """A checker reads these after prose on the same line, `noqa` and `type: ignore` behind a `#`.""" + for text in ( + "# two sentences here. Another one follows. # noqa: E501\n", + "x = 1 # two sentences here. Another one follows. # noqa\n", + "x = 1 # two sentences here. Another one follows. # type: ignore[arg-type]\n", + "# two sentences here. Another one follows. # pylint: disable=no-member\n", + "# two sentences here. Another one follows, pylint: disable=no-member\n", + ): + with self.subTest(text=text.strip()): + self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name="tool.py")) + + def test_a_trailing_marker_must_be_the_whole_directive_word(self) -> None: + text = "# two sentences here. Another one follows. # noqable\n" + self.assertIn("comment-wrap", self.kinds(text, self.COMMENT_RULES, name="tool.py")) + + def test_an_uppercase_python_suffix_is_read_as_python(self) -> None: + """A `#` inside a triple-quoted string is not a comment, whatever case the suffix is in.""" + text = '"""\n# two sentences here. Another one follows.\n"""\n' + for name in ("tool.py", "Tool.PY"): + with self.subTest(name=name): + self.assertEqual([], self.kinds(text, {"comment-wrap", "comment-case"}, name=name)) + + class TestMultiLineStrings(BaitCase): """A string that spans lines hides its markers on every line it covers, not only the first. @@ -5073,11 +5150,7 @@ def test_a_python_file_that_does_not_tokenize_is_left_alone(self) -> None: ) def test_this_rule_reads_a_python_suffix_in_either_case(self) -> None: - """A case-insensitive filesystem hands the same file back under either spelling. - - This rule alone. `comment_wrap_findings` still matches the suffix case-sensitively, which - is filed rather than fixed here, so the two rules read such a file differently. - """ + """A case-insensitive filesystem hands the same file back under either spelling.""" for name in ("mixed.py", "mixed.PY"): with self.subTest(name=name): self.assertEqual([], self.kinds(self.DOCSTRING, {"comment-added"}, name=name)) From 0754530636c0fe1f0ee339cb25d1cd13b0945c4f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 08:54:21 -0700 Subject: [PATCH 16/24] Name Why a Windows Tool Install Failed and Print Docker's Status in UTF-8 (#2811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Two problems surfaced in one run of the menu's "Upgrade the host tools" task on Windows. **A failed install said nothing about why.** A Git for Windows upgrade failed with `winget exited -1978335226` and no reason. The reason was in the installer log winget names: the silent Inno Setup installer answered its own files-in-use dialog with Cancel because a background GnuPG key daemon, which Git for Windows ships and gpg starts for commit signing, was still running from Git's tree. The old holder check matched only a process named after the tool, so it found nothing. On a failed install, `install-tools.ps1` now: - prints winget's code in hex beside the decimal (`0x8A150006`), the form winget documents its codes under, - reads the matching installer log newer than the attempt (`Get-InstallerFailureReason`) and prints its reason entries, continuation lines joined and the home folder masked, - names every process running from the tool's install tree (`Get-ToolHolder`, `Get-InstallRoot`), with a `Stop-Process` command on its own line, but only where a log entry uses the in-use wording or winget's code is a sharing violation or access denial, so an unrelated failure does not blame every open Git Bash. Python's tree is read from the managed line's interpreter rather than from the `py` launcher. **Docker's status printed as mojibake.** `docker desktop stop` and `start` print a UTF-8 check mark, which rendered as three code page 437 characters. The menu runs each task in a child pwsh piped through itself, so the fix is in `menu.ps1`: the console is switched to UTF-8 for the menu's lifetime and restored on exit, which a child pwsh inherits at startup. `Invoke-Utf8Run` covers a direct console run of `install-tools.ps1`, and leaves redirected output alone so a captured log keeps its bytes intact. A menu-shaped parent and child chain decoded `U+0393 U+00A3 U+00F4` before the change and `U+2713` after it. ## Open findings from the local strict review The pre-push edit budget was spent with these two still open. Both are `introduced`, and both are fixed in a879bd3, which a further local strict review pass found clean. 1. `introduced`, fixed in a879bd3: `Get-ToolHolder` reads `.Source` off `$null` when the probe is not on PATH, a terminating error under StrictMode that aborts the run and, for docker, skips `Exit-DockerMaintenance`. 2. `introduced`, fixed in a879bd3: the in-use code list omits winget's own `0x8A150101` (package in use) and `0x8A150103` (file in use). The remaining decimal-only `winget exited` sites in other files are filed as #2810. ## Test plan - `python3 -m unittest tests.test_install_tools` on Windows: new `TestWindowsInstallFailureReason` and `TestWindowsInstallFailureReport` cover the log parsing, the log age and id filters, install-root resolution, both branches of `Invoke-Utf8Run`, and each `Write-InstallFailure` branch. - The failure report rendered against a real Git installer log on a fleet host printed the files-in-use reason and named only the key daemon. - PSScriptAnalyzer, editorconfig-checker, ruff, mypy, and the prose gate pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- host-setup/menu.ps1 | 14 ++ host-setup/windows/install-tools.ps1 | 163 +++++++++++++- tests/test_install_tools.py | 324 +++++++++++++++++++++++++++ 3 files changed, 492 insertions(+), 9 deletions(-) diff --git a/host-setup/menu.ps1 b/host-setup/menu.ps1 index f5c868221..ab7a1c866 100644 --- a/host-setup/menu.ps1 +++ b/host-setup/menu.ps1 @@ -673,12 +673,26 @@ function main { if (-not (Get-Command git -ErrorAction SilentlyContinue)) { die 'git is required' } + # Every task runs in a child process piped through this one, so its output is decoded by the child reading a native tool, encoded again by the child writing to this pipe, and decoded by this process reading it, while whatever a tool writes to stderr goes to the console and is rendered there. + # Each of those follows the console's code page, and on an OEM default a UTF-8 tool such as docker desktop prints its check mark as three unrelated characters, so the console is switched to UTF-8 for the menu's lifetime, which a child pwsh inherits at startup, and put back on the way out. + $previousEncoding = [Console]::OutputEncoding + $switchedEncoding = $false + if (-not [Console]::IsOutputRedirected) { + try { + [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + $switchedEncoding = $true + } catch { + $switchedEncoding = $false + } + } + try { Test-HubCheckout Test-DownstreamCheckout Invoke-InteractiveMenu } finally { Invoke-Cleanup + if ($switchedEncoding) { [Console]::OutputEncoding = $previousEncoding } } } diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index e6bfdd90b..709c335aa 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -182,6 +182,36 @@ function run { return $LASTEXITCODE } +# Whether this process's own output reaches a console rather than a file or a pipe. +# A function of its own so a test can drive the console branch of Invoke-Utf8Run from a harness whose output is always captured. +function Test-OutputToConsole { + return (-not [Console]::IsOutputRedirected) +} + +# The run wrapper above, for a command that writes UTF-8 whatever the console's code page, as Docker's CLI does. +# A console left at its OEM default decodes those bytes as that code page, so the check mark docker desktop prints on success reads as three unrelated characters. +# The console is switched to UTF-8 for this one call and put back after, rather than for the whole run, so every other native tool here keeps the decoding it was written against. +# Output redirected to a file or a pipe is left alone: PowerShell keeps writing it in the encoding it started with, so decoding the command as UTF-8 there would turn the check mark into one lossy byte where it otherwise passes through intact. +# A process with no console at all refuses the switch, and that is not a failure either, since nothing renders the bytes there. +function Invoke-Utf8Run { + param([Parameter(Mandatory)][string]$Command, [Parameter(ValueFromRemainingArguments)][string[]]$Arguments) + $previous = [Console]::OutputEncoding + $switched = $false + if (Test-OutputToConsole) { + try { + [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) + $switched = $true + } catch { + $switched = $false + } + } + try { + return (run -Command $Command -Arguments $Arguments) + } finally { + if ($switched) { [Console]::OutputEncoding = $previous } + } +} + function confirm { param([Parameter(Mandatory)][string]$Question) if ($script:ASSUME_YES -or $script:DRY_RUN) { return $true } @@ -384,6 +414,122 @@ function Invoke-WingetRemove { return (run -Command 'winget' -Arguments $arguments) } +# Where App Installer keeps every installer log winget asks a package for, named .-.log. +$WINGET_LOG_DIRECTORY = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir' + +# The entries of the installer log one failed winget call wrote that say why it failed, or none where no such log or entry exists. +# All winget reports is that the installer exited non-zero, with a code the installer chose, so the reason lives in that log and nowhere a person reads by default. +# A silent Inno Setup installer, Git for Windows among them, answers its own blocking dialog and logs the dialog's text, which is the whole reason, so an entry keeps its indented continuation lines rather than being read one line at a time. +# Capped at the last few matches so a verbose MSI log cannot flood the console, with the log's own path printed beside them for the rest. +# Whether files were in use is read from every entry before that cap, and an in-use entry is always among those shown, the first one put back where none survived the cap, so a run of later error and abort lines cannot push the actual reason out of the report. +function Get-InstallerFailureReason { + param([Parameter(Mandatory)][string]$Id, [Parameter(Mandatory)][datetime]$Since, [string]$Directory = $script:WINGET_LOG_DIRECTORY) + $result = @{ Log = $null; Entries = @(); InUse = $false } + if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return $result } + $log = Get-ChildItem -LiteralPath $Directory -Filter "$Id.*.log" -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -ge $Since } | Sort-Object LastWriteTime | Select-Object -Last 1 + if (-not $log) { return $result } + $result.Log = $log.FullName + $entries = [System.Collections.Generic.List[string]]::new() + foreach ($line in (Get-Content -LiteralPath $log.FullName -ErrorAction SilentlyContinue)) { + if (($line -eq '' -or $line -match '^\s') -and $entries.Count -gt 0) { + $entries[$entries.Count - 1] = "$($entries[$entries.Count - 1]) $($line.Trim())".TrimEnd() + } else { + $entries.Add(($line -replace '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\s+', '').Trim()) + } + } + $inUsePattern = '(?i)process\(es\) use|in use by|used by another process' + $matched = @($entries | Where-Object { $_ -match '(?i)suppressed message box|\berror\b|\bfail|\babort|denied|in use|return value 3' -or $_ -match $inUsePattern }) + $inUse = @($matched | Where-Object { $_ -match $inUsePattern }) + $result.InUse = $inUse.Count -gt 0 + $shown = @($matched | Select-Object -Last 4) + if ($result.InUse -and -not ($shown | Where-Object { $_ -match $inUsePattern })) { + $shown = @($inUse[0]) + @($matched | Select-Object -Last 3) + } + $result.Entries = $shown + return $result +} + +# The directory one installed file's package owns, read as the child of the container it was installed into, or nothing where the file sits in no such container. +# A tool's executable can sit several levels down its own tree and in more than one place, Git for Windows carrying git.exe in both cmd and mingw64\bin, so walking up from the executable a fixed number of levels lands on a different directory depending on which copy PATH resolved. +# Anything outside these containers is left unread rather than guessed at: py.exe sits in the Windows directory on a machine wide Python install, and reading that as Python's tree would name half the host. +function Get-InstallRoot { + param([Parameter(Mandatory)][string]$Path) + $containers = @( + $env:ProgramFiles + ${env:ProgramFiles(x86)} + "$env:ProgramFiles\WinGet\Packages" + "$env:LOCALAPPDATA\Programs" + "$env:LOCALAPPDATA\Programs\Python" + "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" + ) + # Longest first, so a container nested inside another, WinGet's machine wide package directory inside Program Files or the per-user Python directory every line installs beside its siblings in, answers before the one holding it would claim every package in it as one tree. + foreach ($container in ($containers | Where-Object { $_ } | Sort-Object Length -Descending)) { + $prefix = "$($container.TrimEnd('\'))\" + if (-not $Path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { continue } + $rest = $Path.Substring($prefix.Length).Split('\') + # A file sitting directly in the container belongs to no directory of its own, so there is no tree to read. + if ($rest.Count -lt 2) { return $null } + return "$prefix$($rest[0])" + } + return $null +} + +# Every running process whose executable sits inside the tree one tool installed, which is what an installer refusing over files in use is refusing over. +# Matching the tool's own process name misses the helpers a tool ships and leaves running in the background, such as the GnuPG key daemon Git for Windows carries, which outlives the git command that started it and blocks Git's own upgrade. +# Executable names the file to read the tree from where the probe sits outside it, as py does for Python: the launcher installs in a directory of its own, while what blocks an upgrade is the interpreter the line installed. +# A process is matched by the probe's name only where no tree could be read, since once one is, a same-named process running from somewhere else, py.exe beside an upgraded Python line, holds nothing the installer replaces. +function Get-ToolHolder { + param([Parameter(Mandatory)][string]$Probe, [string]$Executable) + $source = $Executable + if (-not $source) { + $command = Get-Command $Probe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { $source = $command.Source } + } + $item = if ($source) { Get-Item -LiteralPath $source -ErrorAction SilentlyContinue } + if ($item -and $item.LinkTarget) { + $resolved = $item.ResolveLinkTarget($true) + if ($resolved) { $item = $resolved } + } + $root = if ($item) { Get-InstallRoot -Path $item.FullName } + if ($root) { + $holders = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith("$root\", [StringComparison]::OrdinalIgnoreCase) }) + } else { + $holders = @(Get-Process -Name $Probe -ErrorAction SilentlyContinue) + } + return , @($holders | Sort-Object Id -Unique) +} + +# What a failed winget call owes its reader: winget's code, the reason the installer's own log gives, and the processes that may hold the tool's files. +# The hex form of the code is the one winget documents its codes under, where the signed decimal matches nothing a search finds. +# The log entries pass through the same home folder masking as the log's path, since an installer's reason commonly names a file under the profile. +# Windows will not replace a file that is open, so the processes running from the tool's install are named only where something says files were in use: an installer log entry in Inno Setup's or Windows' own in-use wording, or winget's code being one of its own package-in-use or file-in-use codes, a sharing violation, or an access denial. +# Anything else names nobody, since a Git upgrade that failed on a download leaves no log and still finds every open Git Bash running from Git's own tree, and blaming those would send the reader after the wrong thing. +function Write-InstallFailure { + param([Parameter(Mandatory)][string]$ToolName, [Parameter(Mandatory)][string]$Package, [Parameter(Mandatory)][int]$Code, [Parameter(Mandatory)][datetime]$Since, [Parameter(Mandatory)][string]$Probe, [string]$Executable) + warn "$ToolName failed on $Package, winget exited $Code ($('0x{0:X8}' -f $Code))" + $reason = Get-InstallerFailureReason -Id $Package -Since $Since + if ($reason.Entries.Count -gt 0) { + info 'The installer log says:' + foreach ($entry in $reason.Entries) { + $shown = if ($HOME) { $entry -ireplace [regex]::Escape($HOME), '%USERPROFILE%' } else { $entry } + info " $shown" + } + } + if ($reason.Log) { info "Full installer log: $(Hide-Home $reason.Log)" } + $logInUse = [bool]$reason.InUse + # 0x8A150101 and 0x8A150103 are winget's own INSTALL_PACKAGE_IN_USE and INSTALL_FILE_IN_USE, which it returns where a manifest maps the installer's exit code to either. + # 0x80070020 and 0x80070005 are ERROR_SHARING_VIOLATION and ERROR_ACCESS_DENIED carried as an HRESULT. + $codeInUse = ('0x{0:X8}' -f $Code) -in @('0x8A150101', '0x8A150103', '0x80070020', '0x80070005') + if (-not ($logInUse -or $codeInUse)) { return } + $running = Get-ToolHolder -Probe $Probe -Executable $Executable + if ($running.Count -eq 0) { return } + info "Running from $ToolName's install, and Windows cannot replace a file a running process holds:" + foreach ($process in $running) { info " $($process.ProcessName) (process $($process.Id))" } + info 'Close them, or stop them with the command below, then run this again:' + info " Stop-Process -Id $($running.Id -join ', ')" +} + # --- Tools --- # Managed tools, in the order a report lists them. @@ -965,7 +1111,7 @@ function Stop-DockerDesktop { return 1 } info 'Stopping Docker Desktop' - $code = run 'docker' @('desktop', 'stop', '--timeout', '90') + $code = Invoke-Utf8Run 'docker' @('desktop', 'stop', '--timeout', '90') if ($code -ne 0) { warn "docker desktop stop exited $code" } return $code } @@ -976,7 +1122,7 @@ function Start-DockerDesktop { return 1 } info 'Starting Docker Desktop' - $code = run 'docker' @('desktop', 'start', '--timeout', '180') + $code = Invoke-Utf8Run 'docker' @('desktop', 'start', '--timeout', '180') if ($code -ne 0) { warn 'docker desktop start did not exit cleanly, start Docker Desktop by hand and check WSL integration per distro' } return $code } @@ -1217,20 +1363,19 @@ function Invoke-ToolApply { if ($script:WITH_OPTIONAL) { $packages += $record.Optional } foreach ($package in $packages) { + $started = Get-Date $code = if ($state.Rows.Count -gt 0 -and $script:MODE -eq 'upgrade' -and $package -eq $target) { Invoke-WingetUpgrade -Id $package } else { Invoke-WingetInstall -Id $package } if ($code -ne 0) { - warn "$ToolName failed on $package, winget exited $code" - # Windows will not replace a file that is open, and winget reports that as an access denial naming the file rather than whatever holds it. - # The holder is usually the tool itself, left running by an editor or a language server, so the process is named here and the reader is spared guessing at a permission problem that is not one. - $running = @(Get-Process -Name $record.Probe -ErrorAction SilentlyContinue) - if ($running.Count -gt 0) { - info "$($record.Probe) is running as process $($running.Id -join ', '), and Windows cannot replace a running executable" - info 'Close whatever is running it, then run this again' + $executable = $null + if ($ToolName -eq 'python') { + $line = Get-PythonLine -Version $state.Installed + if ($line) { $executable = Get-PythonLineInterpreter -Line $line } } + Write-InstallFailure -ToolName $ToolName -Package $package -Code $code -Since $started -Probe $record.Probe -Executable $executable $script:FAILED += $ToolName # Owed regardless of how this returns: Enter-DockerMaintenance stopped Docker Desktop for this attempt, and a failed install is not a reason to leave it down. if ($dockerMaintenance) { Exit-DockerMaintenance -Stopped $dockerMaintenance.Stopped } diff --git a/tests/test_install_tools.py b/tests/test_install_tools.py index 24f4a58da..35669ebd9 100755 --- a/tests/test_install_tools.py +++ b/tests/test_install_tools.py @@ -19,6 +19,7 @@ import tempfile import unittest from pathlib import Path +from typing import Any ROOT = Path(__file__).resolve().parent.parent LINUX_INSTALLER = ROOT / "host-setup" / "linux" / "install-tools.sh" @@ -727,5 +728,328 @@ def test_the_note_follows_what_dotnet_lists(self) -> None: ) +FAILURE_HARNESS = r""" +param([string]$Installer, [string]$Cases) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$tokens, [ref]$errors) +$wanted = @('Get-InstallerFailureReason', 'Get-InstallRoot', 'Invoke-Utf8Run', 'Get-ToolHolder') +foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $wanted -contains $node.Name }, $true)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +$case = Get-Content -Raw -LiteralPath $Cases | ConvertFrom-Json +$results = [ordered]@{} +foreach ($probe in $case.reasons) { + $reason = Get-InstallerFailureReason -Id $probe.id -Since ([datetime]$probe.since) -Directory $case.directory + $results["reason $($probe.id) $($probe.since)"] = [ordered]@{ log = [bool]$reason.Log; entries = @($reason.Entries); in_use = $reason.InUse } +} +$env:ProgramFiles = 'C:\PF' +${env:ProgramFiles(x86)} = 'C:\PF86' +$env:LOCALAPPDATA = 'C:\LAD' +$results['roots'] = @($case.roots | ForEach-Object { [string](Get-InstallRoot -Path $_) }) +$results['absent holders'] = @( + (Get-ToolHolder -Probe 'no-such-tool-on-path').Count + (Get-ToolHolder -Probe 'no-such-tool-on-path' -Executable '').Count +) +if ($IsWindows) { + # This harness runs as pwsh from a directory none of the containers above name, so the name fallback finds it, and a tree read from a file elsewhere does not. + # Read back through Get-Item, which expands an 8.3 short path such as a hosted runner's temp directory, so the container and the file it holds are compared in the same form. + $env:LOCALAPPDATA = Join-Path (Get-Item -LiteralPath $case.directory).FullName 'LAD' + $elsewhere = Join-Path $env:LOCALAPPDATA 'Programs\Tool\tool.exe' + $byName = Get-ToolHolder -Probe 'pwsh' + $byTree = Get-ToolHolder -Probe 'pwsh' -Executable $elsewhere + $results['scoped holders'] = @( + [bool]($byName | Where-Object { $_.Id -eq $PID }) + [bool]($byTree | Where-Object { $_.Id -eq $PID }) + $byTree.Count + ) +} +$before = [Console]::OutputEncoding +$switchable = $true +try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [Console]::OutputEncoding = $before } catch { $switchable = $false } +function run { param([string]$Command, [string[]]$Arguments) $script:INSIDE = [Console]::OutputEncoding.WebName; return 5 } +foreach ($console in @($true, $false)) { + $script:CONSOLE = $console + function Test-OutputToConsole { return $script:CONSOLE } + $script:INSIDE = $null + $results["utf8 console=$console"] = [ordered]@{ + code = (Invoke-Utf8Run 'docker' @('desktop', 'stop')) + switchable = $switchable + before = $before.WebName + inside = $script:INSIDE + restored = ([Console]::OutputEncoding.WebName -eq $before.WebName) + } +} +$results | ConvertTo-Json -Depth 4 +""" + +WRITE_FAILURE_HARNESS = r""" +param([string]$Installer, [string]$Cases) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$tokens = $null +$errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$tokens, [ref]$errors) +$wanted = @('Write-InstallFailure') +foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $wanted -contains $node.Name }, $true)) { + . ([scriptblock]::Create($definition.Extent.Text)) +} +$script:LINES = @() +function warn { param([string]$Message) $script:LINES += "WARN $Message" } +function info { param([string]$Message) $script:LINES += "INFO $Message" } +function Hide-Home { param([string]$Path) return 'LOG' } +function Get-ToolHolder { + param([string]$Probe, [string]$Executable) + $script:LINES += "HOLDERS $Probe $Executable".TrimEnd() + return , @([pscustomobject]@{ ProcessName = 'helper'; Id = 1234 }, [pscustomobject]@{ ProcessName = 'bash'; Id = 99 }) +} +$results = [ordered]@{} +foreach ($case in (Get-Content -Raw -LiteralPath $Cases | ConvertFrom-Json).PSObject.Properties) { + $script:REASON = @{ + Log = 'C:\log.log' + Entries = @($case.Value.entries | ForEach-Object { $_ -replace '', $HOME.Replace('$', '$$') }) + InUse = [bool]($case.Value.PSObject.Properties['in_use'] -and $case.Value.in_use) + } + if ($script:REASON.Entries.Count -eq 0) { $script:REASON.Log = $null } + function Get-InstallerFailureReason { param([string]$Id, [datetime]$Since) return $script:REASON } + $script:LINES = @() + Write-InstallFailure -ToolName 'tool' -Package 'Example.Tool' -Code $case.Value.code -Since (Get-Date) -Probe 'tool' -Executable $case.Value.executable + $results[$case.Name] = @($script:LINES | ForEach-Object { $_.Replace($HOME, '') }) +} +$results | ConvertTo-Json -Depth 4 +""" + +INNO_LOG = [ + "2026-01-02 03:04:05.100 Log opened.", + "2026-01-02 03:04:05.300 Successfully imported the DLL function. Delay loaded? No", + "2026-01-02 03:04:06.000 Defaulting to Cancel for suppressed message box (Retry/Cancel):", + " The following process(es) use Example Tool:", + " ", + " helper.exe (PID 1234)", + " Please terminate those processes and retry.", + "2026-01-02 03:04:06.001 Failed to proceed to next wizard page; aborting.", + "2026-01-02 03:04:06.002 Deinitializing Setup.", +] + +BUSY_LOG = [ + "2026-01-02 03:04:05.100 The file is being used by another process.", + *( + f"2026-01-02 03:04:06.00{index} Error {index}: rollback step failed." + for index in range(5) + ), +] + + +@unittest.skipUnless( + shutil.which("pwsh"), "needs pwsh to drive the Windows installer's own functions" +) +class TestWindowsInstallFailureReason(unittest.TestCase): + """The installer log's reason, a tool's install root, and the UTF-8 run's console handling.""" + + def run_cases(self) -> dict[str, Any]: + with tempfile.TemporaryDirectory() as directory: + log = Path(directory, "Example.Tool.1.2.3-26-01-02-03-04-05.log") + log.write_text("\r\n".join([*INNO_LOG, ""]), encoding="utf-8", newline="") + busy = Path(directory, "Busy.Tool.1.0-26-01-02-03-04-05.log") + busy.write_text("\r\n".join([*BUSY_LOG, ""]), encoding="utf-8", newline="") + elsewhere = Path(directory, "LAD", "Programs", "Tool", "tool.exe") + elsewhere.parent.mkdir(parents=True) + elsewhere.write_bytes(b"") + cases = { + "directory": directory, + "reasons": [ + {"id": "Example.Tool", "since": "2000-01-01"}, + {"id": "Example.Tool", "since": "2100-01-01"}, + {"id": "Other.Tool", "since": "2000-01-01"}, + {"id": "Busy.Tool", "since": "2000-01-01"}, + ], + "roots": [ + "C:\\PF\\Git\\cmd\\git.exe", + "C:\\PF\\Git\\mingw64\\bin\\git.exe", + "C:\\PF86\\Tool\\tool.exe", + "C:\\LAD\\Microsoft\\WinGet\\Packages\\Example.Tool_x\\tool.exe", + "C:\\PF\\WinGet\\Packages\\Example.Tool_x\\tool.exe", + "C:\\LAD\\Programs\\Python\\Python313\\python.exe", + "C:\\Windows\\py.exe", + "C:\\PF\\loose.exe", + ], + } + result = run_pwsh_harness(FAILURE_HARNESS, cases) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def test_the_log_entry_keeps_its_continuation_lines(self) -> None: + results = self.run_cases() + self.assertEqual( + results["reason Example.Tool 2000-01-01"], + { + "log": True, + "entries": [ + ( + "Defaulting to Cancel for suppressed message box (Retry/Cancel): The" + " following process(es) use Example Tool: helper.exe (PID 1234) Please" + " terminate those processes and retry." + ), + "Failed to proceed to next wizard page; aborting.", + ], + "in_use": True, + }, + ) + + def test_an_in_use_entry_survives_the_cap_on_later_errors(self) -> None: + self.assertEqual( + self.run_cases()["reason Busy.Tool 2000-01-01"], + { + "log": True, + "entries": [ + "The file is being used by another process.", + "Error 2: rollback step failed.", + "Error 3: rollback step failed.", + "Error 4: rollback step failed.", + ], + "in_use": True, + }, + ) + + def test_a_log_older_than_the_attempt_or_for_another_id_is_not_read(self) -> None: + results = self.run_cases() + unread = {"log": False, "entries": [], "in_use": False} + self.assertEqual(results["reason Example.Tool 2100-01-01"], unread) + self.assertEqual(results["reason Other.Tool 2000-01-01"], unread) + + @unittest.skipUnless(sys.platform == "win32", "reads this host's own process list") + def test_a_same_named_process_outside_the_tree_is_not_a_holder(self) -> None: + self.assertEqual(self.run_cases()["scoped holders"], [True, False, 0]) + + def test_the_install_root_is_the_containers_child_whichever_copy_resolved(self) -> None: + self.assertEqual( + self.run_cases()["roots"], + [ + "C:\\PF\\Git", + "C:\\PF\\Git", + "C:\\PF86\\Tool", + "C:\\LAD\\Microsoft\\WinGet\\Packages\\Example.Tool_x", + "C:\\PF\\WinGet\\Packages\\Example.Tool_x", + "C:\\LAD\\Programs\\Python\\Python313", + "", + "", + ], + ) + + def test_a_tool_off_path_has_no_holders_rather_than_failing(self) -> None: + self.assertEqual(self.run_cases()["absent holders"], [0, 0]) + + def test_the_utf8_run_switches_only_for_console_output_and_restores_it(self) -> None: + results = self.run_cases() + console = results["utf8 console=True"] + redirected = results["utf8 console=False"] + for utf8 in (console, redirected): + self.assertEqual(utf8["code"], 5) + self.assertTrue(utf8["restored"]) + self.assertEqual(redirected["inside"], redirected["before"]) + if console["switchable"]: + self.assertEqual(console["inside"], "utf-8") + + +INSTALLER_FAILED = -1978335226 +SHARING_VIOLATION = -2147024864 +WINGET_FILE_IN_USE = -1978334973 + + +@unittest.skipUnless( + shutil.which("pwsh"), "needs pwsh to drive the Windows installer's own functions" +) +class TestWindowsInstallFailureReport(unittest.TestCase): + """`Write-InstallFailure` blames running processes only where something says files were in use.""" + + def run_cases(self) -> dict[str, list[str]]: + cases = { + "in use": { + "entries": ["The following process(es) use Example Tool: helper.exe (PID 1234)"], + "in_use": True, + "code": INSTALLER_FAILED, + "executable": "", + }, + "unrelated": { + "entries": [ + "Error 0x80070666: Process returned error at \\AppData\\Local\\x.log" + ], + "code": INSTALLER_FAILED, + "executable": "", + }, + "no reason": {"entries": [], "code": INSTALLER_FAILED, "executable": ""}, + "winget file in use": {"entries": [], "code": WINGET_FILE_IN_USE, "executable": ""}, + "sharing violation": { + "entries": [], + "code": SHARING_VIOLATION, + "executable": "C:\\Tool\\tool.exe", + }, + } + result = run_pwsh_harness(WRITE_FAILURE_HARNESS, cases) + self.assertEqual(result.returncode, 0, result.stderr) + loaded: dict[str, list[str]] = json.loads(result.stdout) + return loaded + + def test_files_in_use_names_the_holders_as_the_cause(self) -> None: + self.assertEqual( + self.run_cases()["in use"], + [ + "WARN tool failed on Example.Tool, winget exited -1978335226 (0x8A150006)", + "INFO The installer log says:", + "INFO The following process(es) use Example Tool: helper.exe (PID 1234)", + "INFO Full installer log: LOG", + "HOLDERS tool", + ( + "INFO Running from tool's install, and Windows cannot replace a file a" + " running process holds:" + ), + "INFO helper (process 1234)", + "INFO bash (process 99)", + "INFO Close them, or stop them with the command below, then run this again:", + "INFO Stop-Process -Id 1234, 99", + ], + ) + + def test_an_unrelated_reason_blames_no_process_and_masks_the_home_folder(self) -> None: + self.assertEqual( + self.run_cases()["unrelated"], + [ + "WARN tool failed on Example.Tool, winget exited -1978335226 (0x8A150006)", + "INFO The installer log says:", + "INFO Error 0x80070666: Process returned error at %USERPROFILE%\\AppData\\Local\\x.log", + "INFO Full installer log: LOG", + ], + ) + + def test_no_reason_blames_no_process(self) -> None: + self.assertEqual( + self.run_cases()["no reason"], + ["WARN tool failed on Example.Tool, winget exited -1978335226 (0x8A150006)"], + ) + + def test_wingets_own_file_in_use_code_names_the_holders(self) -> None: + lines = self.run_cases()["winget file in use"] + self.assertEqual( + lines[:2], + [ + "WARN tool failed on Example.Tool, winget exited -1978334973 (0x8A150103)", + "HOLDERS tool", + ], + ) + + def test_a_sharing_violation_names_the_holders_from_the_executable_given(self) -> None: + lines = self.run_cases()["sharing violation"] + self.assertEqual( + lines[:2], + [ + "WARN tool failed on Example.Tool, winget exited -2147024864 (0x80070020)", + "HOLDERS tool C:\\Tool\\tool.exe", + ], + ) + self.assertEqual(lines[-1], "INFO Stop-Process -Id 1234, 99") + + if __name__ == "__main__": unittest.main() From c1d09a781db8ab3d1f423fdf879fca1c7e1eef0f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 08:57:40 -0700 Subject: [PATCH 17/24] Judge the Prose in Front of a Trailing noqa or pylint Directive (#2814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `NOT_PROSE` in the prose gate exempted a whole comment body carrying a trailing `# noqa`, `# type: ignore`, or `pylint:` directive, so the prose in front of the directive was judged by none of `comment-wrap`, `comment-case`, and `comment-added`. - The trailing alternatives move out of `NOT_PROSE` into `TRAILING_DIRECTIVE`. A new `without_trailing_directive` cuts the directive off the body before both comment rules read it, `comment_wrap_findings` doing so before `config_run_lines`, so a directive's code ending the body cannot make prose read as a configuration run. - The trailing forms apply only in a `.py` file, since no other language's checker reads them, so a TOML, YAML, or shell comment mentioning one mid-sentence is judged as prose. - The test that pinned the old whole-body exemption is replaced by tests for each constructed case in the issue and its comment, a directive-only comment still drawing nothing, and the configuration-run ordering. Each new case fails against the code before the fix. - A regression sweep of the comment rules over the tree reports no change outside the intended one. Closes on promotion: #2807 ## Remaining local review findings The local strict review's edit budget was spent on the first push and again on the second. The two docstring findings listed from the first push are fixed in ed6f9d70. The three `introduced` findings below, listed from the second push, are fixed in 686a95bb662b8b60b5163fb1ba5640650d96e90e, whose own local pass found nothing. - `introduced`: `.github/actions/prose-gate/prose_lint.py`, `comment_wrap_findings` keeps the cut body as the previous line for the continuation check, so a full-line `.py` comment naming a directive mid-sentence, such as one ending `where needed.` after a backticked `# noqa: E501`, is cut to a fragment with no full stop and a lowercase next line is reported as its wrap. - `introduced`: `.github/actions/prose-gate/prose_lint.py`, no test pins the leading `\b` in `\bpylint:`. - `introduced`: `.github/actions/prose-gate/prose_lint.py`, no test pins the `\s*` in `type:\s*ignore`, the spaceless form mypy also reads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 36 ++++++-- tests/test_prose_lint.py | 108 +++++++++++++++++++++-- 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index e19f0f5a8..dd6ba7b64 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1566,14 +1566,14 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # A pragma, shebang, or divider is machinery rather than prose. # A tool's name is anchored at the body's start, since a comment naming one mid-sentence is prose. -# `noqa` and `type: ignore` may also trail prose behind their own `#`, and `pylint:` anywhere. NOT_PROSE = re.compile( r"^(?:!|\s*[-=#*/<>]+\s*$|ruff:|mypy:|(?i:flake8)[:=]|v\d+(?:\.\d+)*$" r"|(?:noqa|type:\s*ignore|pylint|shellcheck|cSpell|markdownlint|omit from toc|prettier|eslint" r"|SPDX|Copyright)\b)" - r"|#\s*(?:noqa|type:\s*ignore)\b|\bpylint:" ) +TRAILING_DIRECTIVE = re.compile(r"#\s*(?:noqa|type:\s*ignore)\b|\bpylint:") + # A comment that is only a URI is a reference, not a sentence, so neither case nor wrap applies. # It cannot be capitalized or restructured without corrupting the address it exists to carry. # A URI inside a sentence is still prose, so the whole body has to be the address and nothing else. @@ -2261,18 +2261,24 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ The rule is one sentence per comment line. A wrapped sentence is the common failure, and a run-on is the other half of the same rule, so both are reported. + + A body cut at a trailing directive continues into the next line only where the whole body does + not end a sentence either, since a directive named mid-sentence leaves a fragment the sentence + goes on past. """ comments = python_comments(raw) if path.suffix.lower() == ".py" else None if comments is None: comments = extracted_comments(path, lines) skip = fenced_lines(lines) - comments = [c for c in comments if c[0] not in skip] + kept = [c for c in comments if c[0] not in skip] + comments = [c._replace(body=without_trailing_directive(path, c.body)) for c in kept] configured = config_run_lines(comments) out: list[tuple[int, str, str]] = [] prev_body = "" + prev_whole = "" prev_no = 0 - for n, body, leading, _raw in comments: + for (n, body, leading, _raw), whole in zip(comments, kept, strict=True): if ( not body or n in configured @@ -2301,6 +2307,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ and leading and prev_body and not SENT_END.search(prev_body) + and not SENT_END.search(prev_whole) and body[:1].islower() ) if continuation: @@ -2327,6 +2334,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ # A trailing comment can start a sentence the next full-line comment continues. # The continuation has to be a full-line comment, since a trailing one annotates its own. prev_body = body + prev_whole = whole.body prev_no = n return out @@ -2352,6 +2360,24 @@ def is_tool_directive(body: str) -> bool: return bool(TOOL_DIRECTIVE.match(body)) and not SENT_END.search(body) +def without_trailing_directive(path: Path, body: str) -> str: + """The comment body up to the first directive a Python checker reads. + + A Python checker reads `noqa` and `type: ignore` behind their own `#` after prose on the same + line, and `pylint:` anywhere in it. Exempting the whole body let a directive appended to any + comment switch every comment rule off for that line. The prose can be deleted and the directive + kept, so the directive and anything after it are cut off, and what is left is judged by the same + exemptions as any other body, an empty one included. + + No other language's checker reads these forms, so in any other file a mention of one is part of + the sentence holding it. + """ + if path.suffix.lower() != ".py": + return body + match = TRAILING_DIRECTIVE.search(body) + return body[: match.start()].rstrip() if match else body + + def is_comment_prose(body: str) -> bool: """Whether a comment body is prose a reader judges rather than an instruction a tool reads.""" return bool( @@ -2428,7 +2454,7 @@ def comment_added_findings(path: Path, raw: str) -> list[tuple[int, str, str]]: out: list[tuple[int, str, str]] = [] for comment in bodies: n = comment.line - if n in seen or not is_comment_prose(comment.body): + if n in seen or not is_comment_prose(without_trailing_directive(path, comment.body)): continue seen.add(n) out.append( diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 24ab4ba39..7f5037833 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -1585,21 +1585,111 @@ def test_a_directive_opening_the_body_is_still_exempt(self) -> None: with self.subTest(name=name, text=text.strip()): self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) - def test_a_directive_behind_its_own_marker_may_trail_prose(self) -> None: - """A checker reads these after prose on the same line, `noqa` and `type: ignore` behind a `#`.""" + def test_the_prose_in_front_of_a_trailing_directive_is_judged(self) -> None: + """A Python checker reads the directive, and the prose in front of it is still prose.""" + both = ["comment-wrap", "comment-case", "comment-added"] + for text, expected in ( + ("# A new prose comment the reviewer never sees. # noqa\n", ["comment-added"]), + ("# two sentences here. Another one follows. # noqa: E501\n", both), + ("x = 1 # two sentences here. Another one follows. # noqa\n", ["comment-wrap"]), + ( + "x = 1 # two sentences here. Another one follows. # type: ignore[arg-type]\n", + ["comment-wrap"], + ), + ("# two sentences here. Another one follows. # pylint: disable=no-member\n", both), + ("# two sentences here. Another one follows, pylint: disable=no-member\n", both), + ): + with self.subTest(text=text.strip()): + self.assertEqual(expected, self.kinds(text, self.COMMENT_RULES, name="tool.py")) + + def test_a_trailing_directive_does_not_make_prose_read_as_configuration(self) -> None: + """A directive's code ends the body where a full stop would, so the cut comes first.""" + text = ( + "# Note: two sentences here. Another one follows. # noqa: E501\n" + "# Note: the value is read once. # noqa: E501\n" + ) + self.assertEqual(["comment-wrap"], self.kinds(text, {"comment-wrap"}, name="tool.py")) + + def test_what_is_left_before_a_directive_keeps_its_exemptions(self) -> None: + """The space in front of the directive is cut with it, so an address or a key is still exempt.""" for text in ( - "# two sentences here. Another one follows. # noqa: E501\n", - "x = 1 # two sentences here. Another one follows. # noqa\n", - "x = 1 # two sentences here. Another one follows. # type: ignore[arg-type]\n", - "# two sentences here. Another one follows. # pylint: disable=no-member\n", - "# two sentences here. Another one follows, pylint: disable=no-member\n", + "# https://example.com/a/very/long/reference/path # noqa: E501\n", + "# Note: # noqa: E501\n", ): with self.subTest(text=text.strip()): self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name="tool.py")) + def test_each_trailing_form_ends_what_is_judged(self) -> None: + """A sentence after the directive is cut with it, whichever form it is and however the suffix is cased.""" + for name, text in ( + ("tool.py", "x = 1 # Read the row once. pylint: disable=no-member\n"), + ("tool.py", "x = 1 # Read the row. # type: ignore. Another one follows.\n"), + ("tool.py", "x = 1 # Read the row. # type:ignore. Another one follows.\n"), + ("tool.py", "x = 1 # Read the row. # noqa. Another one follows.\n"), + ("TOOL.PY", "x = 1 # Read the row. # noqa. Another one follows.\n"), + ): + with self.subTest(name=name, text=text.strip()): + self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) + + def test_a_trailing_directive_alone_is_still_exempt(self) -> None: + """What is left once the directive is cut is empty or a directive `NOT_PROSE` already exempts.""" + for text in ( + "x = 1 # noqa\n", + "x = 1 # pylint: disable=no-member\n", + "x = 1 # type: ignore # noqa: E501\n", + "# noqa: E501 # noqa\n", + "# # noqa\n", + ): + with self.subTest(text=text.strip()): + self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name="tool.py")) + + def test_a_trailing_directive_is_prose_outside_python(self) -> None: + """No other language's checker reads these forms, so a mention of one is part of the sentence.""" + both = ["comment-wrap", "comment-case", "comment-added"] + for name, text in ( + ("a.toml", "# two sentences here. Each line carries a `# noqa` marker.\n"), + ("a.yml", "# two sentences here. Another one follows. # noqa\n"), + ("run.sh", "# two sentences here. Another one follows, pylint: disable=no-member\n"), + ): + with self.subTest(name=name): + self.assertEqual(both, self.kinds(text, self.COMMENT_RULES, name=name)) + + def test_the_sentence_after_a_mention_is_judged_outside_python(self) -> None: + """Only a Python file is cut at the directive, so a run-on after a mention elsewhere is caught.""" + text = "# Each line carries a `# noqa` marker. Another sentence follows.\n" + for name, expected in ( + ("a.yml", ["comment-wrap", "comment-added"]), + ("a.toml", ["comment-wrap", "comment-added"]), + ("tool.py", ["comment-added"]), + ): + with self.subTest(name=name): + self.assertEqual(expected, self.kinds(text, self.COMMENT_RULES, name=name)) + def test_a_trailing_marker_must_be_the_whole_directive_word(self) -> None: - text = "# two sentences here. Another one follows. # noqable\n" - self.assertIn("comment-wrap", self.kinds(text, self.COMMENT_RULES, name="tool.py")) + for text in ( + "x = 1 # Read the row. # noqable. Another one follows.\n", + "x = 1 # Read the row mypylint: once. Another one follows.\n", + ): + with self.subTest(text=text.strip()): + self.assertEqual( + ["comment-wrap"], self.kinds(text, self.COMMENT_RULES, name="tool.py") + ) + + def test_a_directive_named_mid_sentence_leaves_no_wrap(self) -> None: + """The sentence goes on past a directive it names, so the next line does not continue it.""" + for text, expected in ( + ("# Suppress with `# noqa: E501` where needed.\n# max_len - the longest line.\n", []), + ("# Lines carry a `# noqa` marker.\n# flake8 reads it.\n", [(2, "comment-case")]), + ( + "# A sentence that keeps # noqa\n# going onto the next line.\n", + [(1, "comment-wrap")], + ), + ): + with self.subTest(text=text.strip()): + path = self.tmp / "tool.py" + path.write_text(text, encoding="utf-8") + found = prose_lint.check_file(path, {"comment-wrap", "comment-case"}) + self.assertEqual(expected, [(n, kind) for n, kind, _ in found]) def test_an_uppercase_python_suffix_is_read_as_python(self) -> None: """A `#` inside a triple-quoted string is not a comment, whatever case the suffix is in.""" From c0a0eef625fef22bd8e985cbcdb02e0b978cec5a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 09:42:56 -0700 Subject: [PATCH 18/24] Read noqa and CSpell Directives in Any Letter Case (#2819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `NOT_PROSE` in the prose gate matched `noqa` and `cSpell` case-sensitively, though flake8 reads `noqa` in any letter case and CSpell reads its in-document directive in any letter case. A `// cspell:ignore vendorid` in a `.ts` file drew `comment-case` and `comment-added`, and a `# NOQA: E501` in a `.py` file drew `comment-added`. - `noqa` now matches in any case. The CSpell alternative becomes `(?i:c?spell|spell-?checker):`, which covers `cspell:`, `spell:`, `spell-checker:` and `spellchecker:` in any case, as CSpell's own `(?:spell-?checker|c?spell)::?` pattern reads them. It requires the colon, since CSpell reads the directive only with one, so a comment opening a sentence on the tool's name is still prose. No comment in the tree opens on a CSpell name without its colon, so the narrowing changes no existing result. - `TRAILING_DIRECTIVE` reads a trailing `# NOQA` the same way, so the prose in front of it is still cut and judged. - Every other tool name matches exactly as before. - New tests pin each spelling, the trailing forms, and the colon requirement. With the `prose_lint.py` change reverted, 11 subtests fail. The local review pass also found that `nopep8` has the same case defect. It is filed as #2818 and left out of this change, which its handoff scoped to `noqa` and CSpell. Closes on promotion: #2809 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Prose checks now recognize supported `noqa`, `type: ignore`, and spell-check directives regardless of letter case. * Spell-check tool names without the required directive format continue to be checked as prose. --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 6 ++--- tests/test_prose_lint.py | 31 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index dd6ba7b64..1de1b7c45 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1568,11 +1568,11 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # A tool's name is anchored at the body's start, since a comment naming one mid-sentence is prose. NOT_PROSE = re.compile( r"^(?:!|\s*[-=#*/<>]+\s*$|ruff:|mypy:|(?i:flake8)[:=]|v\d+(?:\.\d+)*$" - r"|(?:noqa|type:\s*ignore|pylint|shellcheck|cSpell|markdownlint|omit from toc|prettier|eslint" - r"|SPDX|Copyright)\b)" + r"|(?:(?i:noqa)|type:\s*ignore|pylint|shellcheck|markdownlint|omit from toc|prettier|eslint" + r"|SPDX|Copyright)\b|(?i:c?spell|spell-?checker):)" ) -TRAILING_DIRECTIVE = re.compile(r"#\s*(?:noqa|type:\s*ignore)\b|\bpylint:") +TRAILING_DIRECTIVE = re.compile(r"#\s*(?:(?i:noqa)|type:\s*ignore)\b|\bpylint:") # A comment that is only a URI is a reference, not a sentence, so neither case nor wrap applies. # It cannot be capitalized or restructured without corrupting the address it exists to carry. diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 7f5037833..b945eb484 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -1585,6 +1585,35 @@ def test_a_directive_opening_the_body_is_still_exempt(self) -> None: with self.subTest(name=name, text=text.strip()): self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) + def test_a_directive_is_read_in_any_letter_case_its_tool_reads(self) -> None: + """flake8 reads `noqa` and CSpell reads its own spellings in any letter case.""" + for name, text in ( + ("tool.py", "# NOQA: E501\n"), + ("tool.py", "# NoQA\n"), + ("app.ts", "// cspell:ignore vendorid\n"), + ("app.ts", "// CSPELL: ignore vendorid\n"), + ("app.ts", "// spell-checker: disable\n"), + ("app.ts", "// Spell-Checker:ignore vendorid\n"), + ("app.ts", "// spellchecker:words vendorid\n"), + ("app.ts", "// spell:ignore vendorid\n"), + ("app.ts", "// Spell: disable\n"), + ): + with self.subTest(name=name, text=text.strip()): + self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) + + def test_a_cspell_name_without_its_colon_is_prose(self) -> None: + """CSpell reads its directive only with the colon, so a sentence opening on its name is judged.""" + for name, text in ( + ("app.ts", "// cspell reads the word list from the root.\n"), + ("app.ts", "// spellchecker settings live in the root.\n"), + ("app.ts", "// spell checking reads the word list from the root.\n"), + ): + with self.subTest(name=name, text=text.strip()): + self.assertEqual( + ["comment-case", "comment-added"], + self.kinds(text, self.COMMENT_RULES, name=name), + ) + def test_the_prose_in_front_of_a_trailing_directive_is_judged(self) -> None: """A Python checker reads the directive, and the prose in front of it is still prose.""" both = ["comment-wrap", "comment-case", "comment-added"] @@ -1627,6 +1656,8 @@ def test_each_trailing_form_ends_what_is_judged(self) -> None: ("tool.py", "x = 1 # Read the row. # type:ignore. Another one follows.\n"), ("tool.py", "x = 1 # Read the row. # noqa. Another one follows.\n"), ("TOOL.PY", "x = 1 # Read the row. # noqa. Another one follows.\n"), + ("tool.py", "x = 1 # Read the row. # NOQA. Another one follows.\n"), + ("tool.py", "x = 1 # Read the row. # NoQA: E501. Another one follows.\n"), ): with self.subTest(name=name, text=text.strip()): self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) From b3d7fec9aea73259d17085a11dd3dcb347a0c74e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 10:16:23 -0700 Subject: [PATCH 19/24] Spend a Head Refusal a Later Genuine Round on That Head Answers (#2825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `refusing_review()` returned the newest refusal on the head even where a later genuine Copilot round on that head had answered it. `wait` reaches that read whenever no round past `--min-rounds` covers the head. A genuine round at or below the baseline therefore left a spent quota refusal to report exit `46`, while the digest printed `refusal=no`. - It now reads the newest review on the head and returns it only where that review is a refusal. Its docstring no longer says the caller handles the spent case. - `local_cover` reads the same function, so a refusal a later round on the head spends no longer blocks an attested local pass. Its docstring says so, and a new test pins that shape. ## The exit code for the issue's shape The issue expected `45` from `wait --min-rounds 2` on [quota refusal on `H`, genuine round on `H`]. With the fix that wait exits `30` with the line naming the baseline (`--min-rounds 2 waits for round 3 or later`), because `head_review_done` counts a round only past `--min-rounds`, and `N` is the `rounds=` a `status` read before the re-request, which was `1` there. `wait --min-rounds 1` and `status` both exit `45` (`COVERAGE_IS_UNSTATED`). The new test asserts all three, and none reports `COPILOT_QUOTA_EXHAUSTED`. ## Verification - With the `pr_review.py` change reverted, the `--min-rounds 2` subtest fails `30 != 46` and the `local_cover` test fails `False is not true`. - Full suite green, ruff, mypy, prose gate, and `spec/validate.py` clean. - The local review pass found a pre-existing `scripts/README.md` wording mismatch this change did not cause, filed as #2824. Closes on promotion: #2820 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- scripts/pr_review.py | 21 +++++++++------------ tests/test_pr_review.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/scripts/pr_review.py b/scripts/pr_review.py index bb9ef318d..5e2b01576 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -1555,24 +1555,20 @@ def stopping_refusal(pr: dict) -> dict | None: def refusing_review(pr: dict) -> dict | None: - """The reviewer's newest refusal carrying the current head, where one is there. + """The reviewer's newest review carrying the current head, where that review is a refusal. Head-scoped, unlike a suppressed finding, because a refusal is a statement about one commit: a push retires it, and the round the push raises either reviews that head or refuses it in its own right. - A refusal alongside a genuine review of the same head is spent too, and that is the caller's - reading rather than this one's, since what spends it is coverage this cannot see from a - refusal alone. Both callers hold it: `main` returns 0 on `reviewed_head` before reaching - here, and the digest reports the field only where nothing covers the head. + A genuine round on the same head after the refusal spends it, so the newest review on the + head is the one read rather than the newest refusal. """ head = pr["headRefOid"] - refusals = [ - n - for n in reviewer_nodes(pr, "reviews") - if (n.get("commit") or {}).get("oid") == head and refusal_of(n) - ] - return newest_of(refusals) + newest = newest_of( + [n for n in reviewer_nodes(pr, "reviews") if (n.get("commit") or {}).get("oid") == head] + ) + return newest if newest is not None and refusal_of(newest) else None def newest_of(nodes: list[dict]) -> dict | None: @@ -5048,7 +5044,8 @@ def local_cover(pr: dict) -> bool: a head its writer attests, and only where nothing on record says any round read part of a diff and the whole review history is in view, the bound a file table stands in under. A fix push after the first round is covered by the local pass, and a promotion keeps its own - Copilot round. A refusal on this head is Copilot's own word on it and is never overruled. + Copilot round. A refusal on this head is Copilot's own word on it, which no attestation + overrules, and only a later round of Copilot's own on the head spends it. """ return ( not refusing_review(pr) diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index dba1cd8e2..16140fad4 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -7681,6 +7681,35 @@ def test_an_older_file_count_refusal_still_yields_to_coverage(self) -> None: out, _ = pr_review.digest("o", "r", 7) self.assertIn("refusal=no", out) + def test_a_later_round_on_the_head_spends_a_quota_refusal_there(self) -> None: + """A quota refusal on the head, then a genuine round on that same head stating no + coverage. The genuine round is the newer word, so the refusal is spent and nothing reads + it as the account state. `--min-rounds 2` waits past the genuine round here, so the wait + names that baseline rather than the refusal. `--min-rounds 1` and `status` both reach + the coverage gap itself. + """ + refused = review(body=QUOTA_REFUSED, at=EARLY, rid="PRR_refused") + both = payload([refused, review(body=OVERVIEW, at=LATE, rid="PRR_genuine")]) + before = payload([refused], pending=True) + wait = ["wait", "7", "--ignore-quota-signal", "--timeout", "0", "--min-rounds"] + cases = ( + ([*wait, "2"], (before, both), 30, "--min-rounds 2 waits for round 3"), + ([*wait, "1"], (before, both), 45, "status=COVERAGE_IS_UNSTATED"), + (["status", "7"], (both,), 45, "status=COVERAGE_IS_UNSTATED"), + ) + for argv, reads, code, said in cases: + with self.subTest(argv=argv): + self.out.seek(0) + self.out.truncate() + self.answer(*reads) + self.wire_history([]) + with mock.patch.object(pr_review.time, "sleep"): + self.assertEqual(code, self.cli(argv)) + out = self.out.getvalue() + self.assertIn(said, out) + self.assertIn("refusal=no", out) + self.assertNotIn("COPILOT_QUOTA_EXHAUSTED", out) + def test_a_fix_push_after_the_first_round_is_not_requested(self) -> None: self.answer(self.into(payload([review(oid=OLD)]))) calls = self.wire_history([hist_review(7, OVERVIEW + "\n" + COVERED)]) @@ -8341,6 +8370,15 @@ def test_a_refusal_on_the_head_is_not_overruled_by_an_attestation(self) -> None: pr = self.into(payload(rounds), attest=True) self.assertFalse(pr_review.local_cover(pr)) + def test_a_later_round_on_the_head_lets_an_attestation_past_a_spent_refusal(self) -> None: + rounds = [ + review(oid=OLD, at=EARLY, rid="PRR_a"), + review(body=QUOTA_REFUSED, at="2026-08-02T10:30:00Z", rid="PRR_b"), + review(body=OVERVIEW, at=LATE, rid="PRR_c"), + ] + pr = self.into(payload(rounds), attest=True) + self.assertTrue(pr_review.local_cover(pr)) + def test_status_reads_an_attested_head_unless_a_partial_is_on_record(self) -> None: part = OVERVIEW + "\n" for rounds, field in ( From 65c14a282fc73e80828959a6e86128fb2edabbce Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 10:32:09 -0700 Subject: [PATCH 20/24] Read nopep8 in Any Letter Case in the Prose Gate (#2827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - pycodestyle reads `nopep8` in any letter case, since it compiles its inline pattern `# no(?:qa|pep8)\b` with `re.I`. The prose gate's `TOOL_DIRECTIVE` matched `nopep8` only in lower case, so `# NOPEP8` and `# NoPep8: E501` in a `.py` file were judged as new prose comments and drew `comment-added`. - `TOOL_DIRECTIVE` now reads `nopep8` in any case. - `TRAILING_DIRECTIVE` did not list `nopep8` at all, so prose followed by a trailing `# nopep8` on the same line was not cut. It now cuts a trailing `nopep8` in any case, as it already did for `noqa`. - The `without_trailing_directive` docstring names `nopep8` beside `noqa` and `type: ignore`. - New constructed tests pin both leading spellings, two trailing forms, and a trailing directive standing alone. With the `prose_lint.py` change reverted, 5 subtests fail. Closes on promotion: #2818 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 14 +++++++------- tests/test_prose_lint.py | 7 ++++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 1de1b7c45..2c87eb20a 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1572,7 +1572,7 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: r"|SPDX|Copyright)\b|(?i:c?spell|spell-?checker):)" ) -TRAILING_DIRECTIVE = re.compile(r"#\s*(?:(?i:noqa)|type:\s*ignore)\b|\bpylint:") +TRAILING_DIRECTIVE = re.compile(r"#\s*(?:(?i:noqa|nopep8)|type:\s*ignore)\b|\bpylint:") # A comment that is only a URI is a reference, not a sentence, so neither case nor wrap applies. # It cannot be capitalized or restructured without corrupting the address it exists to carry. @@ -1671,7 +1671,7 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # So a name here is exempt from that one rule, and a name in `NOT_PROSE` from all three. TOOL_DIRECTIVE = re.compile( r"^(?:syntax=|escape=|fmt:\s*(?:on|off|skip)\b|pragma:|nosec\b|checkov:|hadolint\b" - r"|renovate:|yaml-language-server:|-\*-\s*coding[:=]|pyright:|isort:|nopep8\b" + r"|renovate:|yaml-language-server:|-\*-\s*coding[:=]|pyright:|isort:|(?i:nopep8)\b" r"|codespell:|doctest:)" ) @@ -2363,11 +2363,11 @@ def is_tool_directive(body: str) -> bool: def without_trailing_directive(path: Path, body: str) -> str: """The comment body up to the first directive a Python checker reads. - A Python checker reads `noqa` and `type: ignore` behind their own `#` after prose on the same - line, and `pylint:` anywhere in it. Exempting the whole body let a directive appended to any - comment switch every comment rule off for that line. The prose can be deleted and the directive - kept, so the directive and anything after it are cut off, and what is left is judged by the same - exemptions as any other body, an empty one included. + A Python checker reads `noqa`, `nopep8`, and `type: ignore` behind their own `#` after prose on + the same line, and `pylint:` anywhere in it. Exempting the whole body let a directive appended to + any comment switch every comment rule off for that line. The prose can be deleted and the + directive kept, so the directive and anything after it are cut off, and what is left is judged by + the same exemptions as any other body, an empty one included. No other language's checker reads these forms, so in any other file a mention of one is part of the sentence holding it. diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index b945eb484..9f97db1d0 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -1586,10 +1586,12 @@ def test_a_directive_opening_the_body_is_still_exempt(self) -> None: self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) def test_a_directive_is_read_in_any_letter_case_its_tool_reads(self) -> None: - """flake8 reads `noqa` and CSpell reads its own spellings in any letter case.""" + """flake8, pycodestyle, and CSpell each read their own directive in any letter case.""" for name, text in ( ("tool.py", "# NOQA: E501\n"), ("tool.py", "# NoQA\n"), + ("tool.py", "# NOPEP8\n"), + ("tool.py", "# NoPep8: E501\n"), ("app.ts", "// cspell:ignore vendorid\n"), ("app.ts", "// CSPELL: ignore vendorid\n"), ("app.ts", "// spell-checker: disable\n"), @@ -1658,6 +1660,8 @@ def test_each_trailing_form_ends_what_is_judged(self) -> None: ("TOOL.PY", "x = 1 # Read the row. # noqa. Another one follows.\n"), ("tool.py", "x = 1 # Read the row. # NOQA. Another one follows.\n"), ("tool.py", "x = 1 # Read the row. # NoQA: E501. Another one follows.\n"), + ("tool.py", "x = 1 # Read the row. # nopep8. Another one follows.\n"), + ("tool.py", "x = 1 # Read the row. # NoPep8: E501. Another one follows.\n"), ): with self.subTest(name=name, text=text.strip()): self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name=name)) @@ -1670,6 +1674,7 @@ def test_a_trailing_directive_alone_is_still_exempt(self) -> None: "x = 1 # type: ignore # noqa: E501\n", "# noqa: E501 # noqa\n", "# # noqa\n", + "# # NOPEP8\n", ): with self.subTest(text=text.strip()): self.assertEqual([], self.kinds(text, self.COMMENT_RULES, name="tool.py")) From 5bfd54cf3a6b79c5f26a995fd09ad113131e50bd Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 10:59:38 -0700 Subject: [PATCH 21/24] Bind the Workspace-Edit Rule Only to an Agent Running Inside VS Code (#2815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The rule against editing a loaded `.code-workspace` file applied wherever the editing agent ran, so agents in a standalone console kept handing trivial workspace edits back to the maintainer. A VS Code reload drops only a session that VS Code hosts. The rule now binds an agent running inside VS Code, in its extension or its integrated terminal, and an agent in a standalone console edits the file directly, even while a window has it loaded. - `GOVERNANCE.md` "Verification Discipline": scopes the rule to an agent hosted by VS Code. It keeps the exemption for a file no window has loaded, and asks the maintainer only when the agent cannot tell whether the rule binds it. - `GOVERNANCE.md` "Editor and Tasks": the pointer bullet now states the same split. - `agent-conduct` Skill: regenerated, since it carries "Verification Discipline" as an include. ## Verification - `scripts/build_dist.py --check` reports the generated distributions are current. - `prose_lint.py --diff` against the merge base reports no violations. - `spec/validate.py` passes. - The markdownlint container reports 0 errors. - Three recorded `local-strict-review` passes ran. The first two raised findings: the dropped not-loaded exemption, an unreliable environment-variable sign, and an over-broad fallback. Each was fixed. The final pass raised none. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/agent-conduct/SKILL.md | 2 +- .claude-plugin/fleet-skills/.source-digests/agent-conduct | 2 +- .claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md | 2 +- .github/skills/agent-conduct/SKILL.md | 2 +- GOVERNANCE.md | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/agent-conduct/SKILL.md b/.agents/skills/agent-conduct/SKILL.md index c9b9c3960..a47e480e0 100644 --- a/.agents/skills/agent-conduct/SKILL.md +++ b/.agents/skills/agent-conduct/SKILL.md @@ -50,7 +50,7 @@ The checks that separate work actually done from work that merely reports succes - **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. - **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. - **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<` exclusion marker). -- **Agents: editing a `.code-workspace` that a VS Code window has loaded as its workspace can reload that window and drop an agent session running in it.** "Verification Discipline" holds the rule: leave that edit to the maintainer (a maintainer edit does not reload), and edit directly a workspace file that no window has loaded. Opening the folder rather than the workspace is what keeps a checkout's own file editable. +- **Agents: editing a `.code-workspace` that a VS Code window has loaded as its workspace can reload that window and drop an agent session running in it.** "Verification Discipline" holds the rule. An agent running inside VS Code leaves that edit to the maintainer (a maintainer edit does not reload). An agent in a standalone console edits the file directly, and so does any agent editing a workspace file that no window has loaded. Opening the folder rather than the workspace is what keeps a checkout's own file editable. ## Repository Details From f3a633b2eafa5aa077597879958c716e3549a0c7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 11:03:57 -0700 Subject: [PATCH 22/24] Record HomeAssistant-Config's Incidental Python as a driftNote (#2813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This records HomeAssistant-Config's incidental Python as a registry `driftNote`, which suppresses the audit's undeclared-type advisory for it, per `spec/type-model.md`'s undeclared-but-detected row. `spec/audit.py HomeAssistant-Config` reported this advisory in a resync run (`audit run 2026-10-11T14:06:55Z | hub 54e96f86`): > DRIFT registry: tracked .py file(s) present on the tree but types omits 'python'. The repository tracks two root helpers that Home Assistant runs at runtime: - `resize_snapshot.py` is a `shell_command`. Its only third-party import is Pillow, which comes from the Home Assistant environment. - `github_repo_status.py` is a `command_line` sensor and uses only the standard library. The repository has no `pyproject`, no declared dependencies, and no tests. Declaring the `python` type would ask for a Python project the repository does not have, so the note gives that reason with the `(python.directories.declared)` token. ## Verification - `python3 spec/validate.py` passes. - `python3 spec/audit.py --branch develop HomeAssistant-Config` no longer reports any `registry:` finding, and no other finding changes. - The diff is one line and preserves the file's formatting. - Two local strict review passes were run. The first found that the note called both scripts stdlib, and that is fixed. The second found nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- registry/repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/repos.json b/registry/repos.json index 55e67e568..4a525dc04 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -221,7 +221,7 @@ "requiredSecrets": [], "consumerModel": "pull", "releaseTrigger": "dispatch-only", - "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational onboarding completed 2026-07-17 (HomeAssistant-Config #16): master->main rename + develop created, advisory lint CI (Check pull request workflow status job required check), dispatch-only source release (version.json + NBGV + publish-release.yml), repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the CODEGEN_APP_* pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json). The baseline was promoted develop->main via HomeAssistant-Config #17.", "groundTruthBranch intentionally main: develop is the working branch (direct signed commits), main the promoted stable snapshot the audit targets - deliberately not flipped to develop (ptr727/ProjectTemplate#340).", "Private, deployed by git pull into the HA config dir."] + "driftNotes": ["Home Assistant CONFIGURATION (configuration.yaml + automations/blueprints), NOT a HACS integration (no custom_components/manifest.json, no hacs.json).", "Operational onboarding completed 2026-07-17 (HomeAssistant-Config #16): master->main rename + develop created, advisory lint CI (Check pull request workflow status job required check), dispatch-only source release (version.json + NBGV + publish-release.yml), repo-config operational carry (rulesets/settings applied and verified in sync), Dependabot + App merge-bot with the CODEGEN_APP_* pair in both stores, adapted self-audit (AUDIT.md + spec/secrets.json). The baseline was promoted develop->main via HomeAssistant-Config #17.", "groundTruthBranch intentionally main: develop is the working branch (direct signed commits), main the promoted stable snapshot the audit targets - deliberately not flipped to develop (ptr727/ProjectTemplate#340).", "Private, deployed by git pull into the HA config dir.", "Two incidental Python helpers sit at the root, resize_snapshot.py (a shell_command that imports Pillow from the Home Assistant environment) and github_repo_status.py (a command_line sensor, stdlib only), which Home Assistant runs at runtime with its bundled interpreter. They are not a Python project, with no pyproject, no declared dependencies, and no tests, so the python type is not declared (python.directories.declared)."] }, { "name": "DevKitCIoT", From 3bb3c955abb5a754ea98709a4899cf9f64d3ee63 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 11:14:24 -0700 Subject: [PATCH 23/24] Require Every CLI Script to Answer Help and Refuse Unknown Arguments (#2832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the CODESTYLE.md General "Command-Line Arguments" rule (every CLI script answers `--help` and refuses unknown arguments before it reads or writes anything) and the `tests/test_cli_usage.py` gate that enforces it, and brings the existing scripts into line. Closes on promotion: #2285 Known items, each with its class: - `spec/audit.py` runs `git config --get remote.origin.url` on import, so scripts importing it read before parsing arguments (introduced, tracked in #2829). - The three agent-safety hooks run by name are exempted in `NOT_COMMAND_LINE` (introduced, tracked in #2786). - `--check sentence-length` hits on CODESTYLE.md Shell paragraph sentences (pre-existing). - Local review findings on the gate's soundness (unknown-flag assertion accepts a "usage" substring, ignored paths and env passthrough not covered, detached grandchildren unseen, a leading-hyphen NAME now read as an option in `spec/resolve_description.py`) (introduced, deferred to #2831). Local gates run: ruff format and check, mypy, prose gate, `repo_gate.py`, `spec/validate.py`, `build_dist.py --check`, and the full unittest suite (2550 tests, OK). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/python-codestyle/SKILL.md | 30 ++- .agents/skills/shell-codestyle/SKILL.md | 32 ++- .../.source-digests/python-codestyle | 2 +- .../.source-digests/shell-codestyle | 2 +- .../skills/python-codestyle/SKILL.md | 30 ++- .../skills/shell-codestyle/SKILL.md | 32 ++- .../python-directories/python_directories.py | 10 +- .github/skills/python-codestyle/SKILL.md | 30 ++- .github/skills/shell-codestyle/SKILL.md | 32 ++- CODESTYLE.md | 9 +- catalog/snippets/hub-fetch-run.py | 14 +- host-setup/windows/install-tools.ps1 | 2 + host-setup/windows/setup-wsl.ps1 | 3 + spec/fidelity_honesty.py | 12 +- spec/resolve_description.py | 14 +- spec/validate.py | 8 +- spec/workflow_reuse.py | 18 +- tests/test_cli_usage.py | 227 ++++++++++++++++++ 18 files changed, 422 insertions(+), 85 deletions(-) create mode 100755 tests/test_cli_usage.py diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index 3d13e248a..e68c830e2 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -4,15 +4,16 @@ description: >- Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test - conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, - or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only - scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or - reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a - helper function", "silence this lint warning", "add a dependency") or verification step ("run - the tests"), because choosing pytest before reading the profile turns an intentional unittest - suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo - with no Python has no use for this Skill. + conventions, versioning, and the --help and unknown-argument rule for a script. Use this + whenever writing, reviewing, or editing a .py file, a pyproject.toml, or a uv.lock, whenever + running or choosing a Python formatting, lint, type-check, or test command, whenever deciding + whether a Python subtree is a shippable project or a lint-only scripts tree, whenever choosing + pyright versus mypy for a repo's CI gate, or whenever writing or reviewing a Python test. + Triggers even when the task looks like a small local fix ("just add a helper function", "silence + this lint warning", "add a dependency") or verification step ("run the tests"), because choosing + pytest before reading the profile turns an intentional unittest suite into a false + missing-dependency diagnosis. Applies only to a repo's Python side, a repo with no Python has no + use for this Skill. --- # Python Codestyle @@ -247,6 +248,17 @@ Before pushing or opening a PR: - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + + [docs-link]: https://docs.pytest.org/ diff --git a/.agents/skills/shell-codestyle/SKILL.md b/.agents/skills/shell-codestyle/SKILL.md index 1708f1dfc..99f15b37e 100644 --- a/.agents/skills/shell-codestyle/SKILL.md +++ b/.agents/skills/shell-codestyle/SKILL.md @@ -4,16 +4,17 @@ description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the - shellcheck-plus-shfmt clean-compile, and the why-not-what comment rule. Use this whenever - writing, reviewing, or editing a shell script (a `.sh` file, or an extensionless bash/sh shebang - script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline - built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure, or - whenever a script or probe writes a wrapper or shim named after a command it also calls. - Triggers even when the task looks like a one-line tweak to an existing script, because a missing - `-e`/`pipefail`, or a reader piped straight from a producer that closes the pipe early, are each - invisible until the exact failure mode they guard against actually happens. Fleet-wide: a shell - script can appear in any repo (a bootstrap that installs the interpreter, a host tool that must - run before a toolchain exists), not only a repo whose primary language is shell. + shellcheck-plus-shfmt clean-compile, the --help and unknown-argument rule, and the why-not-what + comment rule. Use this whenever writing, reviewing, or editing a shell script (a `.sh` file, or + an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash + or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it + silently swallowed a failure, or whenever a script or probe writes a wrapper or shim named after + a command it also calls. Triggers even when the task looks like a one-line tweak to an existing + script, because a missing `-e`/`pipefail`, or a reader piped straight from a producer that + closes the pipe early, are each invisible until the exact failure mode they guard against + actually happens. Fleet-wide: a shell script can appear in any repo (a bootstrap that installs + the interpreter, a host tool that must run before a toolchain exists), not only a repo whose + primary language is shell. --- # Shell Codestyle @@ -75,3 +76,14 @@ depend on Python either. Everything else is Python, with a test under its own sc - **Comments say why, never what.** The code states what it does. A comment restating it goes stale silently, where a comment carrying a reason fails visibly when the reason stops being true. + +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index 66bd085bd..3f2b2d701 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -4a55b75c694378d2 +aee8b6541472e858 diff --git a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle index 188cb067b..b98d14882 100644 --- a/.claude-plugin/fleet-skills/.source-digests/shell-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/shell-codestyle @@ -1 +1 @@ -52454c88dd2aedc0 +3880094e1fe1db20 diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index 3d13e248a..e68c830e2 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -4,15 +4,16 @@ description: >- Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test - conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, - or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only - scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or - reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a - helper function", "silence this lint warning", "add a dependency") or verification step ("run - the tests"), because choosing pytest before reading the profile turns an intentional unittest - suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo - with no Python has no use for this Skill. + conventions, versioning, and the --help and unknown-argument rule for a script. Use this + whenever writing, reviewing, or editing a .py file, a pyproject.toml, or a uv.lock, whenever + running or choosing a Python formatting, lint, type-check, or test command, whenever deciding + whether a Python subtree is a shippable project or a lint-only scripts tree, whenever choosing + pyright versus mypy for a repo's CI gate, or whenever writing or reviewing a Python test. + Triggers even when the task looks like a small local fix ("just add a helper function", "silence + this lint warning", "add a dependency") or verification step ("run the tests"), because choosing + pytest before reading the profile turns an intentional unittest suite into a false + missing-dependency diagnosis. Applies only to a repo's Python side, a repo with no Python has no + use for this Skill. --- # Python Codestyle @@ -247,6 +248,17 @@ Before pushing or opening a PR: - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + + [docs-link]: https://docs.pytest.org/ diff --git a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md index 1708f1dfc..99f15b37e 100644 --- a/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/shell-codestyle/SKILL.md @@ -4,16 +4,17 @@ description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the - shellcheck-plus-shfmt clean-compile, and the why-not-what comment rule. Use this whenever - writing, reviewing, or editing a shell script (a `.sh` file, or an extensionless bash/sh shebang - script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline - built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure, or - whenever a script or probe writes a wrapper or shim named after a command it also calls. - Triggers even when the task looks like a one-line tweak to an existing script, because a missing - `-e`/`pipefail`, or a reader piped straight from a producer that closes the pipe early, are each - invisible until the exact failure mode they guard against actually happens. Fleet-wide: a shell - script can appear in any repo (a bootstrap that installs the interpreter, a host tool that must - run before a toolchain exists), not only a repo whose primary language is shell. + shellcheck-plus-shfmt clean-compile, the --help and unknown-argument rule, and the why-not-what + comment rule. Use this whenever writing, reviewing, or editing a shell script (a `.sh` file, or + an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash + or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it + silently swallowed a failure, or whenever a script or probe writes a wrapper or shim named after + a command it also calls. Triggers even when the task looks like a one-line tweak to an existing + script, because a missing `-e`/`pipefail`, or a reader piped straight from a producer that + closes the pipe early, are each invisible until the exact failure mode they guard against + actually happens. Fleet-wide: a shell script can appear in any repo (a bootstrap that installs + the interpreter, a host tool that must run before a toolchain exists), not only a repo whose + primary language is shell. --- # Shell Codestyle @@ -75,3 +76,14 @@ depend on Python either. Everything else is Python, with a test under its own sc - **Comments say why, never what.** The code states what it does. A comment restating it goes stale silently, where a comment carrying a reason fails visibly when the reason stops being true. + +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + diff --git a/.github/actions/python-directories/python_directories.py b/.github/actions/python-directories/python_directories.py index 7224e78d1..279b5e7f5 100755 --- a/.github/actions/python-directories/python_directories.py +++ b/.github/actions/python-directories/python_directories.py @@ -23,6 +23,7 @@ from __future__ import annotations +import argparse import io import os import posixpath @@ -30,7 +31,7 @@ import sys import tomllib import uuid -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path UNCOVERED_SHOWN = 10 @@ -127,7 +128,10 @@ def uncovered(directories: list[str], tracked: list[str]) -> list[str]: ] -def main() -> int: +def main(argv: Sequence[str] = ()) -> int: + argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None).parse_args( + list(argv) + ) # A tracked path need not be valid UTF-8, and a warning naming one must not crash the step it only warns in. if isinstance(sys.stdout, io.TextIOWrapper): sys.stdout.reconfigure(errors="backslashreplace") @@ -187,4 +191,4 @@ def read_once(path: str) -> str: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index 3d13e248a..e68c830e2 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -4,15 +4,16 @@ description: >- Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test - conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a - pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, - or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only - scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or - reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a - helper function", "silence this lint warning", "add a dependency") or verification step ("run - the tests"), because choosing pytest before reading the profile turns an intentional unittest - suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo - with no Python has no use for this Skill. + conventions, versioning, and the --help and unknown-argument rule for a script. Use this + whenever writing, reviewing, or editing a .py file, a pyproject.toml, or a uv.lock, whenever + running or choosing a Python formatting, lint, type-check, or test command, whenever deciding + whether a Python subtree is a shippable project or a lint-only scripts tree, whenever choosing + pyright versus mypy for a repo's CI gate, or whenever writing or reviewing a Python test. + Triggers even when the task looks like a small local fix ("just add a helper function", "silence + this lint warning", "add a dependency") or verification step ("run the tests"), because choosing + pytest before reading the profile turns an intentional unittest suite into a false + missing-dependency diagnosis. Applies only to a repo's Python side, a repo with no Python has no + use for this Skill. --- # Python Codestyle @@ -247,6 +248,17 @@ Before pushing or opening a PR: - Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, packaged as the `comment-and-doc-style` Skill. +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + + [docs-link]: https://docs.pytest.org/ diff --git a/.github/skills/shell-codestyle/SKILL.md b/.github/skills/shell-codestyle/SKILL.md index 1708f1dfc..99f15b37e 100644 --- a/.github/skills/shell-codestyle/SKILL.md +++ b/.github/skills/shell-codestyle/SKILL.md @@ -4,16 +4,17 @@ description: >- Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the - shellcheck-plus-shfmt clean-compile, and the why-not-what comment rule. Use this whenever - writing, reviewing, or editing a shell script (a `.sh` file, or an extensionless bash/sh shebang - script), whenever deciding whether a new script should be Bash or Python, or whenever a pipeline - built from `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure, or - whenever a script or probe writes a wrapper or shim named after a command it also calls. - Triggers even when the task looks like a one-line tweak to an existing script, because a missing - `-e`/`pipefail`, or a reader piped straight from a producer that closes the pipe early, are each - invisible until the exact failure mode they guard against actually happens. Fleet-wide: a shell - script can appear in any repo (a bootstrap that installs the interpreter, a host tool that must - run before a toolchain exists), not only a repo whose primary language is shell. + shellcheck-plus-shfmt clean-compile, the --help and unknown-argument rule, and the why-not-what + comment rule. Use this whenever writing, reviewing, or editing a shell script (a `.sh` file, or + an extensionless bash/sh shebang script), whenever deciding whether a new script should be Bash + or Python, or whenever a pipeline built from `curl`/`grep`/`jq`-style commands looks like it + silently swallowed a failure, or whenever a script or probe writes a wrapper or shim named after + a command it also calls. Triggers even when the task looks like a one-line tweak to an existing + script, because a missing `-e`/`pipefail`, or a reader piped straight from a producer that + closes the pipe early, are each invisible until the exact failure mode they guard against + actually happens. Fleet-wide: a shell script can appear in any repo (a bootstrap that installs + the interpreter, a host tool that must run before a toolchain exists), not only a repo whose + primary language is shell. --- # Shell Codestyle @@ -75,3 +76,14 @@ depend on Python either. Everything else is Python, with a test under its own sc - **Comments say why, never what.** The code states what it does. A comment restating it goes stale silently, where a comment carrying a reason fails visibly when the reason stops being true. + +## Command-Line Arguments + + + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + + diff --git a/CODESTYLE.md b/CODESTYLE.md index 719905334..94ca521d9 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -34,6 +34,13 @@ Each language defines a **clean-compile** verification: the combination of build - **Let the real operation be the check, not a prediction of it.** Attempt the read, the write, or the call itself, and turn its failure into a clean refusal. Do not probe first with an existence, type, or permission test. Where the operation could block or succeed on the wrong kind of target, judge what it opened instead. A probe is a second model of the operation. It misjudges each case its author did not foresee, such as a dangling link, a link loop, or a platform's own permission model. The operation cannot disagree with itself. - **A failure message states the error, the path or other target, and the remedy, and claims nothing about the state the failure left.** The error the operation raised is the one fact the code holds at that point. A description of what the failure left behind is a prediction. It reads false once an unforeseen state reaches the message, such as a file written in full that then fails to close. A remedy such as "fix what it names, then re-run" needs no such claim. +### Command-Line Arguments + +A command-line script is one a person or an agent runs by name. A hook its host runs, taking its input from that host rather than from a command line, is not one. + +- **Every command-line script answers `--help` and `-h`** by printing its usage and exiting 0, changing nothing. An agent learns a script's interface by asking it, so a script that reads `--help` as an input acts in a way nobody predicted. +- **An argument the script does not recognize is refused**, with a non-zero exit before any read or write. An unrecognized first argument is never taken as a default mode or as a target. + ### Markdown and Spelling These apply repo-wide, in every directory: Markdown lints clean via `markdownlint-cli2` against the shared config, spelling is US English via CSpell against the shared `cspell.json`, the CI spelling gate covers `README.md` and `HISTORY.md` only, `HISTORY.md` mirrors the README's opening, and "Markdown" is a proper noun in prose. A repo excluding a subtree of its own that it does not treat as authored prose, a committed data archive, a vendored theme, or a hand-maintained record, puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content rather than editing the shared root config, whose contents are fleet-fixed. Those `ignores` patterns resolve against the directory holding them rather than against the repo root, so a repo-root-relative entry there matches nothing and reports no error saying so, and excluding through the CI workflow's own negated Markdown glob input instead is a CI-only fix that leaves the same files flagged for anyone running the linter locally. The full rules are in the `comment-and-doc-style` Skill referenced above. @@ -56,7 +63,7 @@ This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codes ## Shell -Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on Python either. Everything else is Python, with a test under its own scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. Run the clean-compile check itself per `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo, not by probing `command -v shellcheck`. +Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on Python either. Everything else is Python, with a test under its own scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-recursive command shims, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. A script's own arguments follow "Command-Line Arguments" under General, which the Skill carries whole. Run the clean-compile check itself per `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo, not by probing `command -v shellcheck`. diff --git a/catalog/snippets/hub-fetch-run.py b/catalog/snippets/hub-fetch-run.py index f9354901a..cc68b2e8a 100644 --- a/catalog/snippets/hub-fetch-run.py +++ b/catalog/snippets/hub-fetch-run.py @@ -102,12 +102,16 @@ def resolve_unborn_head(argv: list[str]) -> list[str]: return out +USAGE = "usage: hub-fetch-run.py [script-args...]" + + def main(argv: list[str]) -> int: - if not argv: - print( - "hub-fetch-run: usage: hub-fetch-run.py [script-args...]", - file=sys.stderr, - ) + if argv[:1] in (["-h"], ["--help"]): + print(USAGE) + 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) return 2 hub_path, script_args = argv[0], resolve_unborn_head(argv[1:]) is_prose_gate = hub_path == PROSE_GATE_PATH diff --git a/host-setup/windows/install-tools.ps1 b/host-setup/windows/install-tools.ps1 index 709c335aa..0d81b8a0b 100644 --- a/host-setup/windows/install-tools.ps1 +++ b/host-setup/windows/install-tools.ps1 @@ -1462,6 +1462,8 @@ function Resolve-Selection { function main { if ($script:WANT_HELP) { usage; exit 0 } + $unknown = @($script:WANT_TOOLS | Where-Object { $_ -like '-*' }) + if ($unknown.Count -gt 0) { die "Unknown option `"$($unknown[0])`", -Help lists the options" } $script:MODE = Resolve-Mode Test-HostSupported Add-RepositoryToolCatalog diff --git a/host-setup/windows/setup-wsl.ps1 b/host-setup/windows/setup-wsl.ps1 index 1de1958e6..cc5f0282d 100644 --- a/host-setup/windows/setup-wsl.ps1 +++ b/host-setup/windows/setup-wsl.ps1 @@ -338,7 +338,10 @@ function Resolve-Mode { function main { if ($script:WANT_HELP) { usage; exit 0 } + $unknown = @($script:WANT_NAMES | Where-Object { $_ -like '-*' }) + if ($unknown.Count -gt 0) { die "Unknown option `"$($unknown[0])`", -Help lists the options" } $script:MODE = Resolve-Mode + if ($script:MODE -ne 'install' -and $script:WANT_NAMES.Count -gt 0) { die "Only -Install takes a distribution, and `"$($script:WANT_NAMES[0])`" was given" } switch ($script:MODE) { 'list' { Show-List } diff --git a/spec/fidelity_honesty.py b/spec/fidelity_honesty.py index c43a3881b..11ae380f6 100755 --- a/spec/fidelity_honesty.py +++ b/spec/fidelity_honesty.py @@ -21,6 +21,7 @@ Usage: python3 spec/fidelity_honesty.py [--report] """ +import argparse import base64 import sys @@ -379,9 +380,12 @@ def buckets(path): return "\n".join(out).rstrip() + "\n" -def main(): - argv = sys.argv[1:] - report_mode = "--report" in argv +def main(argv=()): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument( + "--report", action="store_true", help=f"write {REPORT_PATH} rather than print" + ) + report_mode = parser.parse_args(list(argv)).report spec = { "registry": audit.load("registry/repos.json"), "files": audit.load("spec/files.json"), @@ -442,4 +446,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/spec/resolve_description.py b/spec/resolve_description.py index c9b756152..5ec0b42c9 100755 --- a/spec/resolve_description.py +++ b/spec/resolve_description.py @@ -15,6 +15,7 @@ from __future__ import annotations +import argparse import json import sys from pathlib import Path @@ -71,11 +72,12 @@ def resolve_description(registry: dict, name: str) -> str | None: return desc -def main() -> int: - if len(sys.argv) != 3: - print("usage: resolve_description.py REGISTRY_PATH NAME", file=sys.stderr) - return 1 - registry_path, name = sys.argv[1], sys.argv[2] +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument("registry_path", metavar="REGISTRY_PATH") + parser.add_argument("name", metavar="NAME") + args = parser.parse_args(argv) + registry_path, name = args.registry_path, args.name try: registry = json.loads(Path(registry_path).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: @@ -92,4 +94,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/spec/validate.py b/spec/validate.py index 94bb9389b..53a6caef5 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -7,6 +7,7 @@ dry-run the CI lint job runs; it needs no third-party packages. """ +import argparse import collections import fnmatch import json @@ -594,7 +595,10 @@ def utf8_console() -> None: reconfigure(encoding="utf-8", errors="replace") -def main(): +def main(argv=()): + argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None).parse_args( + list(argv) + ) utf8_console() errors = [] repos = load("registry/repos.json") @@ -1537,4 +1541,4 @@ def check_selector(where, applies_to): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py index ca91b0f4e..66eab4433 100755 --- a/spec/workflow_reuse.py +++ b/spec/workflow_reuse.py @@ -24,6 +24,7 @@ Usage: python3 spec/workflow_reuse.py [--report] [--selftest] """ +import argparse import base64 import difflib import re @@ -398,9 +399,16 @@ def reader(slug, path, ref): return 0 -def main(): - argv = sys.argv[1:] - if "--selftest" in argv: +def main(argv=()): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None) + parser.add_argument( + "--report", action="store_true", help=f"write {REPORT_PATH} rather than print" + ) + parser.add_argument( + "--selftest", action="store_true", help="run the offline self-test and exit" + ) + args = parser.parse_args(list(argv)) + if args.selftest: return _selftest() registry = audit.load("registry/repos.json") hub_entry = next( @@ -409,7 +417,7 @@ def main(): ) hub_slug = audit.repo_slug(hub_entry) rows, unreadable, empty = measure(registry, canonical_texts(), hub_slug) - if "--report" in argv: + if args.report: content = render_report(rows, unreadable, empty, hub_sha()) # LF matches the fleet default, since reports/*.md is LF. # Bytes are written so the local platform does not re-translate them. @@ -435,4 +443,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py new file mode 100755 index 000000000..64569cc0d --- /dev/null +++ b/tests/test_cli_usage.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Hold every tracked command-line entry point to CODESTYLE.md's argument rule by running it. + +Each entry point runs with `--help`, with `-h`, and with an unknown `--bogus-flag`. Stubs ahead of +the real commands on PATH record any call to gh or another network or host-changing command, +no credential is passed, and the proxy is a local listener that records every connection made to +it. Help exits 0 printing its usage. The unknown flag exits non-zero with a refusal that names it or +prints the usage, rather than a crash. No run reaches a stub or the listener, or changes the working +tree. + +The set is discovered from the tree rather than listed, so a new script is held to the rule the +moment it is tracked. A hook its own host runs is not a command-line entry point and is listed in +NOT_COMMAND_LINE. A Windows script builds paths from APPDATA and LOCALAPPDATA as it loads, so the +sandbox sets both to keep a run off Windows from failing before the script's own argument handling. +A parser missing a required argument refuses on that before it reaches the unknown one, so a +refusal printing the usage passes as well as one naming the flag. The case is skipped on Windows, +where a stub without an executable extension shadows nothing and the real commands would run. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import unittest +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from host_capability import bash_or_skip + +ROOT = Path(__file__).resolve().parents[1] + +NOT_COMMAND_LINE = { + "catalog/snippets/devcontainer/dotnet/post-create.sh": "a devcontainer lifecycle hook", + "catalog/snippets/devcontainer/python/post-create.sh": "a devcontainer lifecycle hook", + "host-setup/agent-safety/claude/gh-write-guard.py": "a Claude Code hook", + "host-setup/agent-safety/claude/stray-process-sweep.py": "a Claude Code hook", + "host-setup/agent-safety/claude/tool-containment.py": "a Claude Code hook", + "host-setup/agent-safety/claude/test_install.py": "a test module", +} + +RECORDED = ("gh", "curl", "wget", "ssh", "sudo", "apt-get", "winget", "docker", "uv", "uvx") + + +def entry_points() -> list[str]: + """Every tracked shell, PowerShell, or Python script run by name, tests and hooks aside.""" + listed = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "-s", "-z", "--", "*.sh", "*.ps1", "*.py"], + capture_output=True, + check=True, + ).stdout.decode("utf-8") + found = [] + for record in filter(None, listed.split("\0")): + mode, path = record.split(" ", 1)[0], record.split("\t", 1)[1] + if path.startswith(("tests/", ".husky/")) or path in NOT_COMMAND_LINE: + continue + if path.endswith(".py") and mode != "100755": + text = (ROOT / path).read_text(encoding="utf-8") + if 'if __name__ == "__main__":' not in text: + continue + found.append(path) + return sorted(found) + + +def tree_state() -> str: + return subprocess.run( + ["git", "-C", str(ROOT), "status", "--porcelain=v1", "-uall"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ).stdout + + +@contextmanager +def recording_proxy() -> Iterator[tuple[str, list[int]]]: + """A proxy URL whose listener counts each connection and answers none, so every fetch fails.""" + connections: list[int] = [] + stop = threading.Event() + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.bind(("127.0.0.1", 0)) + server.listen() + server.settimeout(0.1) + + def accept() -> None: + while not stop.is_set(): + try: + peer, _ = server.accept() + except TimeoutError: + continue + connections.append(1) + peer.close() + + worker = threading.Thread(target=accept, daemon=True) + worker.start() + try: + yield f"http://127.0.0.1:{server.getsockname()[1]}", connections + finally: + stop.set() + worker.join() + + +@unittest.skipIf(os.name == "nt", "a stub with no executable extension shadows nothing on Windows") +class EntryPointCase(unittest.TestCase): + """Help answers with usage and exit 0, and `--bogus-flag` is refused, each touching nothing.""" + + def setUp(self) -> None: + self.before = tree_state() + self.addCleanup(self.assert_tree_unchanged) + + def assert_tree_unchanged(self) -> None: + self.assertEqual(tree_state(), self.before, "a help or refused run changed the tree") + + def command(self, path: str) -> list[str]: + if path.endswith(".py"): + return [sys.executable, str(ROOT / path)] + if path.endswith(".sh"): + return [bash_or_skip(), str(ROOT / path)] + pwsh = shutil.which("pwsh") + if pwsh is None: + raise unittest.SkipTest("no pwsh on PATH to run a PowerShell entry point") + return [pwsh, "-NoProfile", "-NonInteractive", "-File", str(ROOT / path)] + + def run_entry(self, path: str, flag: str) -> tuple[subprocess.CompletedProcess[str], str]: + command = self.command(path) + with tempfile.TemporaryDirectory() as tmp: + scratch = Path(tmp) + stubs = scratch / "bin" + stubs.mkdir() + calls = scratch / "calls" + for name in RECORDED: + stub = stubs / name + stub.write_text( + f'#!/bin/sh\necho "{name} $*" >>"{calls}"\nexit 1\n', encoding="utf-8" + ) + stub.chmod(0o755) + home = scratch / "home" + home.mkdir() + appdata = {name: str(scratch / name) for name in ("APPDATA", "LOCALAPPDATA")} + env = { + key: value + for key, value in os.environ.items() + if key + not in ( + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_OUTPUT", + "GITHUB_ENV", + "GITHUB_PATH", + "GITHUB_STEP_SUMMARY", + ) + } + with recording_proxy() as (proxy, connections): + env.update( + PATH=f"{stubs}{os.pathsep}{os.environ.get('PATH', os.defpath)}", + HOME=str(home), + **appdata, + PYTHONDONTWRITEBYTECODE="1", + HTTP_PROXY=proxy, + HTTPS_PROXY=proxy, + http_proxy=proxy, + https_proxy=proxy, + NO_PROXY="", + no_proxy="", + ) + result = subprocess.run( + [*command, flag], + capture_output=True, + stdin=subprocess.DEVNULL, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + check=False, + env=env, + cwd=ROOT, + ) + recorded = calls.read_text(encoding="utf-8") if calls.exists() else "" + recorded += "proxy connection\n" * len(connections) + return result, recorded + + def test_help_prints_usage_and_exits_zero(self) -> None: + for path in entry_points(): + for flag in ("--help", "-h"): + with self.subTest(path=path, flag=flag): + result, calls = self.run_entry(path, flag) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("usage", result.stdout.lower(), result.stdout) + self.assertEqual(calls, "") + + def test_an_unknown_flag_is_refused(self) -> None: + for path in entry_points(): + with self.subTest(path=path): + result, calls = self.run_entry(path, "--bogus-flag") + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertTrue("bogus-flag" in output or "usage" in output.lower(), output) + self.assertNotIn("Traceback", result.stderr) + self.assertEqual(calls, "") + + def test_discovery_finds_each_kind(self) -> None: + """The control: a discovery that matched nothing would pass both cases above vacuously.""" + found = entry_points() + for expected in ( + "repo-config/configure.sh", + "scripts/handoff.py", + "scripts/prose_lint.py", + "catalog/snippets/hub-fetch-run.py", + "host-setup/bootstrap.ps1", + ): + with self.subTest(expected=expected): + self.assertIn(expected, found) + for excluded in NOT_COMMAND_LINE: + with self.subTest(excluded=excluded): + self.assertTrue( + (ROOT / excluded).is_file(), "an exclusion names a file the tree no longer has" + ) + + +if __name__ == "__main__": + unittest.main() From 4035555295845e7dd8fe938149d1922fb527dacd Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 11:39:36 -0700 Subject: [PATCH 24/24] Exempt nopep8 as a Leading Directive in the Prose Gate (#2833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `nopep8` (any letter case) to the prose gate's `NOT_PROSE` pattern beside `noqa`, so a comment line opening on it is exempt from comment-wrap, comment-case and comment-added exactly as `noqa` is. Removes the `nopep8` alternative from `TOOL_DIRECTIVE`, which was unreachable once `NOT_PROSE` is tested first. This answers an open Copilot finding on the develop to main promotion PR. Known items, each with its class: - The `nopep8` exemption gives up the sentence-end guard `is_tool_directive` applied, and is not limited to Python files. This matches how `noqa` already behaves (intended parity, not a regression). - No test row pins the lowercase `# nopep8` spelling for comment-case (introduced, minor; the existing rows are capitalised). Closes on promotion: none (finding on the promotion PR only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 4 ++-- tests/test_prose_lint.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 2c87eb20a..ae4d23351 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1568,7 +1568,7 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # A tool's name is anchored at the body's start, since a comment naming one mid-sentence is prose. NOT_PROSE = re.compile( r"^(?:!|\s*[-=#*/<>]+\s*$|ruff:|mypy:|(?i:flake8)[:=]|v\d+(?:\.\d+)*$" - r"|(?:(?i:noqa)|type:\s*ignore|pylint|shellcheck|markdownlint|omit from toc|prettier|eslint" + r"|(?:(?i:noqa|nopep8)|type:\s*ignore|pylint|shellcheck|markdownlint|omit from toc|prettier|eslint" r"|SPDX|Copyright)\b|(?i:c?spell|spell-?checker):)" ) @@ -1671,7 +1671,7 @@ def resume_at(carry: Carried, line: str) -> tuple[Carried, int | None]: # So a name here is exempt from that one rule, and a name in `NOT_PROSE` from all three. TOOL_DIRECTIVE = re.compile( r"^(?:syntax=|escape=|fmt:\s*(?:on|off|skip)\b|pragma:|nosec\b|checkov:|hadolint\b" - r"|renovate:|yaml-language-server:|-\*-\s*coding[:=]|pyright:|isort:|(?i:nopep8)\b" + r"|renovate:|yaml-language-server:|-\*-\s*coding[:=]|pyright:|isort:" r"|codespell:|doctest:)" ) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 9f97db1d0..4f1b4f357 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -1592,6 +1592,8 @@ def test_a_directive_is_read_in_any_letter_case_its_tool_reads(self) -> None: ("tool.py", "# NoQA\n"), ("tool.py", "# NOPEP8\n"), ("tool.py", "# NoPep8: E501\n"), + ("tool.py", "# NOPEP8: E501. Another sentence follows.\n"), + ("tool.py", "# nopep8\n"), ("app.ts", "// cspell:ignore vendorid\n"), ("app.ts", "// CSPELL: ignore vendorid\n"), ("app.ts", "// spell-checker: disable\n"),