guard: the reviewer checklist's rule numbers are unique, contiguous and stable - #1885
Conversation
…nd stable The rules are cited by number from outside the file — the file's own header names three (ADR-028 rule 23, ADR-019 rule 9, REVIEW.md §7) — and the rules cite each other nine more times. A number is a name, and nothing mechanical noticed when two PRs claimed one: on 2026-09-25 main gained a rule 34 (#1877) while an open PR added its own rule 34. Git caught that one as a text conflict only because both appended at the file's end; an insert mid-file conflicts less reliably, and a keep-both merge of two rule 34s would ship silently — the ADR-018 shape, where an author followed the wrong member of a duplicate pair. scripts/verify-numbered-rules.js checks four things: the numbers are unique, they ascend 1..N with no gap, every `rule N` / `rules N–M` citation inside the file resolves and a range ascends, and against a reference version no rule has changed its NUMBER or its LEAD sentence. The reference is main as it is right now, not the merge base: a merge base predates whatever landed while the PR was open, which is the only state the collision occurs in. What it deliberately does not check is in the script's header: a rule present in the reference and absent here (the normal state of a PR that predates a rule someone else merged), an edit to a rule's body, and citations from other files. Evidence: 9-case mutation campaign, each mutation asserted to apply exactly once, baseline green before and after.
This guard redded on its own first CI run (36137344359): the checker is loaded from a temp dir so a PR branched before the guard landed still runs main's copy, and the default path was resolved from __dirname — /tmp/.. — so it looked for /tmp/docs/development/review-checklist.md and died with ENOENT rather than reporting anything about rule numbers. Default now comes from process.cwd(), and the workflow passes --file explicitly, so the path does not depend on where the script itself was loaded from. Same shape as adr-numbering-guard.yml's --dir, which exists for this reason.
The guard has no bypass, so the first person who needs to withdraw a rule will
try the two routes that fail. Measured, not reasoned — m9-m11 in the campaign,
which reproduce sprint-review's counts:
- append the withdrawal to the rule's BODY, lead intact -> green (m9), the
number stays claimed and citations of it still resolve;
- mark the lead "Withdrawn" -> 1 error, reported as a number claimed twice
(m10), which is what the docblock already warns;
- delete the rule and close the gap -> 14 errors on a 34-rule file, every
rule after it moved (m11).
The middle one is the interesting failure: the sentence a reader would write to
say "this is gone" is the one that makes the rule look replaced.
|
Arrival check against a moved base, since main advanced twice under this PR and one of those commits edits the file it guards.
PR-arm check as CI will run it (branch file, So the push arm will be green on merge, and nothing here needs a re-gate from #1916. |
Resets the stale-base distance to 0 so the freshness guard actually evaluates this head (it does not re-run as main advances).
|
Head moved Why. This PR crossed the stale-base ceiling while displaying a green guard. At 13:23Z it was 38 behind; at 14:47Z it was exactly 40 (the test is The part worth recording, because it is the reason this was fixed rather than left pressable. Merging does not require an up-to-date branch, and a merge does not trigger the guard, so a PR can cross the ceiling and merge on a green that was measured at a smaller distance — the gate never evaluates the distance it exists to bound. That is the same shape as #1876, which was 45 behind on a frozen green before the press. A frozen green is not a passing gate; here the instrument is the distance, not the check: Fix, and it is verified rather than hoped. Merged Not verified: the rest of CI on |
|
Re-stamp requested at The branch's own commits, oldest → newest: The per-file counts differ, so the reviewed artifact is not the artifact now on the head — a re-read rather than a carry. Nothing is wrong with the earlier verdict; it is simply bound to a sha.
|
|
PASS at
Correction to my comment above. I wrote that the PASS was at What I measured was true and about the wrong object. I verified the branch's history — which commits exist, and that the PR's own diff moved between the two shas I was handed — but never asked the gate which sha it was bound to. Ask the gate for its sha; do not take the sha from the person requesting the re-gate, because the requester is the one surface that cannot see the gate's own record. Same class as the head citation on #1876 earlier today, where The input that actually moved, which neither I nor the presser named. A guard's verdict is a function of the script and the corpus it reads. The script never changed after |
Why
docs/development/review-checklist.mdnumbers its 34 rules, and those numbers are names that other documents cite. The file's own header names three external citations (ADR-028rule 23,ADR-019rule 9,REVIEW.md§7), and the rules cite each other nine more times (rule 5,rule 7,rule 9,rule 12twice,rule 14,rule 16,rule 23,rules 27–28). Nothing mechanical checked any of it.That cost a real collision today. #1877 landed a
rule 34on main while an open PR added its own rule 34. Git happened to catch that one as a text conflict, because both appended at the end of the file. An insert mid-file conflicts less reliably, and a "keep both" merge of two rule 34s would have shipped with every check green — the ADR-018 shape, where a duplicate number survived long enough that a PR author followed the wrong member of the pair and shipped a wake-policy regression (#963).adr-numbering-guard.ymlexists for that; this is the same guard one file over.What it checks
scripts/verify-numbered-rules.js(alsonpm run verify:numbered-rules):rule N/rules N–Minside the file points at a rule that exists, and a range ascends. Lists (rules 5, 7 and 9) and en/em dashes are handled; fenced code is skipped.The one design decision worth reviewing
The PR arm's reference is main as it is right now, not the merge base.
adr-numbering-guard.ymlgoes to some trouble to build the post-merge tree from current main, and for the same reason: a merge base predates whatever landed while the PR was open, and that window is exactly where this collision lives. Casem7below is that state, and it only reds with main as the reference.The push arm re-checks main afterwards, because two PRs that each insert at a different place can both be green and still collide once both have merged. Like the ADR guard, that arm cannot prevent it — it makes main say so within a minute.
What it deliberately does not check, stated in the script header because a guard that overstates itself is worse than none:
rule Nan ADR means, which is the ambiguity this guard exists to keep from growing; they are protected by keeping the numbers and the names stable instead.Where a retraction goes, since there is no bypass and the first person who needs one will try the two routes that fail: append the withdrawal to the rule's body and leave its number and lead alone (m9, green; citations of that number still resolve, to a rule that says it is withdrawn). Editing the lead to say so reads as a replacement (m10), and deleting the rule renumbers everything after it (m11). That paragraph is in the script's header, not just here.
Evidence
12-case mutation campaign (
/tmp/rulecheck_campaign.py), each mutation asserted to apply exactly once — a mutation that never applied would read as green — with a byte-identical restore and a green baseline after:--previous— this is the blind spot--previous= mainrule 99rules 27–28reversed to28–27rule 34)The workflow YAML was parsed rather than eyeballed; there is no actionlint in this repo. The PR arm's script-resolution fallback (read the checker from main if the PR head predates it) is copied from
adr-numbering-guard.yml, where #1504 earned it: without it, every PR branched before the guard landed reds withMODULE_NOT_FOUNDand no rule output, which reads as a broken guard.The arrival check — a new gate has to not red on day one, and this one was measured against the two trees it will actually see (
sprint-review, 74018, against main after #1884 merged):git merge-tree)The branch-vs-main staleness difference is m8 above, now the live state rather than a synthetic one. The PR's only edit to
review-checklist.mdis the one header line pointing at the guard; it touches no rule.The guard's own first run was red, which is why
--fileis explicitRun
36137344359:ENOENT: no such file or directory, open '/tmp/docs/development/review-checklist.md'. The PR arm loads the checker from a temp dir (so a PR branched before the guard landed still runs main's copy), and the script resolved its default path from__dirname—/tmp/... So the job died before it looked at a single rule number.Fixed in
6c19fb77: the default now resolves fromprocess.cwd(), and the workflow passes--fileexplicitly so the path cannot depend on where the script itself was loaded from.adr-numbering-guard.yml's--direxists for the same reason; this keeps the trick but stops paying for it twice.The current run is green:
· merge base a7dea33a; main is a7dea33athen✓ 34 rules, numbers 1..34 ascending with no gap, 10 citations all resolve, and no rule changed its number or its name.Not verified
cat-filebranch is exercised only by reading); the fallback prints that it is skipping the reference comparison rather than passing silently.paths:filter is intentionally absent, so this runs on every PR. It is a ~2 s job and a path filter would skip exactly the case of the script itself changing.