Repository navigation
Catch packages leaving an enforced rule and new ratcheted rules, and give each downgrade an open tracking issue - #913
Merged
Merged
Conversation
A [rule.downgrade] section now needs `issue`, the number of the issue that tracks restoring the rule, next to a non-empty `reason`. The loader reports an issue that isn't a positive whole number (including TOML's `true`), an empty reason, a section that isn't a table and unknown keys, and reads such a section as absent, so it never allows a weaker rule. The parsed section becomes a Downgrade(reason, issue) in place of Rule.downgrade_reason. The guard's note for a downgrade names the issue, and its fix for a weakened status says that only a person may add the section. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules guard compared each rule's status with the base branch, but not the packages a Pyright rule lists. Moving a package out of an enforced rule into a new ratcheted or advisory rule passed, because a new rule has no status on the base branch to compare with. check_enforced_packages now holds each package that an enforced Pyright rule lists on the base branch, and whose pyproject.toml still exists, to an enforced Pyright rule on the head. Moving it to another enforced rule, or deleting it, passes. Moving it to a rule that isn't enforced needs [rule.downgrade] on that rule; listing it in no rule fails. A rule that keeps the package under the same id is left to the status comparison, so a ratcheted rule is reported once. Only Pyright enforcers list packages: the schema rejects `projects` on every other kind, and a package that moves between two enforced rules keeps its check only if both run the same checker. The base comparison moves out of run_guard into _compare_with_base, to keep run_guard within ruff's complexity limits. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rule the base branch doesn't have produced only the note "rule ... is new", so a new rule could start ratcheted, with a baseline of tolerated violations, and no [rule.downgrade]. Such a rule is weaker than an enforced one from the start. check_baselines now fails a new ratcheted rule whose baseline count is above 0 unless it has [rule.downgrade], and notes the reason and issue when it has one. A new ratcheted rule with a count of 0 passes. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check_pr_description.py's lookup raised DescriptionError with messages that end in how to fix the description, so another check couldn't reuse it. require_open_issue now looks an issue up on its own: it raises NotAnOpenIssueError when the number names a missing, moved or closed issue or a pull request (with the issue's state when it is a closed issue here), and IssueLookupError when GitHub can't be asked or its answer isn't an issue. Their messages say only what is wrong. main() adds the same fixes as before, so the PR description check's messages don't change, except that the two lookup failures that said only "Re-run this job." now also say to check the GH_TOKEN the workflow gives the job, as the third one already did. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules guard now looks up the issue of every [rule.downgrade] in the registry on every run (scripts/rules/tracking_issues.py), with the PR description check's require_open_issue, which runs gh api. A number that names a missing, moved or closed issue, or a pull request, fails. gh reads its token from GH_TOKEN or GITHUB_TOKEN. On GitHub Actions, neither being set, a lookup that fails, gh not running, or a lookup script that doesn't load or define the lookup, fails the guard. Outside GitHub Actions, without a token, the guard notes each issue it didn't check instead; with a token, it fails as in CI. scripts/run_quality_guards.py now prints the rules guard's notes, as it already did the ci-scripts guard's, so that note is visible there. The registry names the repository with a new top-level key, issue_repository, so local runs and CI look issues up in the same place. A downgrade in a registry without it, and a downgrade on an enforced rule, are schema violations. The second keeps the issue's lifecycle consistent: the pull request that restores a rule must remove its section, so closing the issue afterwards doesn't fail the guard on every later pull request. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules guard fails on GitHub Actions when it can't look up a downgrade's tracking issue, so the quality-guards job now gives the step that runs scripts/run_quality_guards.py the job's token as GH_TOKEN, which gh reads. The guards run in that process, and gh inherits its environment. The job's permissions are now only contents: read, for the checkout, and issues: read, for the lookup. The rules tool's tests run in an earlier step without the token. test_quality_guards_workflow.py pins both: the job's permissions, and that only the guard step gets the token. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package comparison skipped every base rule that wasn't enforced. A package could move from a ratcheted Pyright rule into a new advisory rule with no [[rule.ci]], leave the pyright job's matrix and stop being type-checked in CI, with only the note "rule ... is new". The status comparison already treats the same change of a whole rule, ratcheted to advisory, as a downgrade. check_enforced_packages becomes check_pyright_packages. A package from a ratcheted rule must now stay in an enforced or ratcheted Pyright rule, unless the rule that lists it now has [rule.downgrade]. A move between ratcheted rules passes here; check_baselines compares the counts. Both comparisons share _weakens, so they agree on which status changes weaken a rule. Found in the self-review. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[[rule.exception]] issue accepted TOML's `true`, which Python reads as a bool and so as the int 1, although the loader's message says the field must be a positive issue number. It now uses the same _issue_number check as [rule.downgrade] issue, which rejects a bool. Found in the self-review; the behaviour predates this branch. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix for a failed tracking-issue lookup always told the reader to check GH_TOKEN, but gh also reads GITHUB_TOKEN, and the guard looks the issue up when only that one is set. The fix now names the variable the token came from, the first of GH_TOKEN and GITHUB_TOKEN that is set, as gh picks it. The rules-registry guide's commands paragraph said only that the commands need nothing beyond the standard library; it now also says that the guard runs gh api for a [rule.downgrade]. Found in the self-review. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegistrySchemaTest runs the whole rules guard on registries that have a [rule.downgrade], but it didn't put the fake gh first on PATH or clear the token variables. With GH_TOKEN or GITHUB_TOKEN set, as in a shell where gh is logged in, test_an_enforced_rule_has_no_downgrade ran gh api for issue 912 in example/repo; a gh on PATH that records its calls showed the call. The class now uses TrackingIssueTestCase, like the other lookup tests, and the same run records no call. Found in the self-review. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard took the repository to look a downgrade's issue up in from issue_repository in the registry under review, so a pull request could change that one line to another public repository and name an open issue there. On GitHub Actions the runner sets GITHUB_REPOSITORY, which a pull request can't change; when it is set and differs from issue_repository (ignoring case), the guard now fails before it looks anything up. Outside GitHub Actions the registry's value is used. The test case that isolates the lookup also clears GITHUB_REPOSITORY, which the runner sets in the step that runs the rules tests. Found in the self-review. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The self-review found three statements in the type-checking and rules-registry guides that the code doesn't bear out: - Moving a package to a rule at least as strong passes the package comparison, but a new ratcheted rule with a count above 0 still needs [rule.downgrade]. The guides, and a test comment, said the move passes. - Closing the tracking issue fails the guard only while the [rule.downgrade] section is in the registry. Removing the section from a rule that stays ratcheted or advisory passes, so the issue can then be closed without failing anything. The type-checking guide now lists that under "It doesn't check". - The guard compares rules by id, so removing a rule or replacing it with a weaker rule under a new id produces only notes, apart from Pyright packages and new ratcheted rules. The rules-registry guide now says so. Refs #912 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
rule-weakening-guard
branch
from
October 26, 2026 17:56
795c73a to
66a940b
Compare
anth-volk
marked this pull request as ready for review
October 26, 2026 18:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #912
Same-Repository Draft PR Check
PolicyEngine/policyengine-sim-api, not a personal fork.Summary
Stacked on #877. The rules guard (
python -m scripts.rules guard, run in CI byscripts/run_quality_guards.py) comparesrules/registry.tomlwith the base branch's registry. Before this PR it failed when a baseline count grew (a ratcheted rule's baseline is the file of violations it still tolerates) and when a rule's own status weakened without[rule.downgrade] reason. This PR closes the gaps that #877's type-checking guide listed, and makes every downgrade name an open tracking issue:pyproject.tomlstill exists, must stay in a Pyright rule at least as strong: enforced for a package from an enforced rule, enforced or ratcheted for one from a ratcheted rule. Otherwise the guard fails, unless the rule that lists the package now has[rule.downgrade]. A package that no rule lists fails too. Moving a package to a rule at least as strong, or deleting it, passes.[rule.downgrade].[rule.downgrade]now needsissue, next to a non-emptyreason. The loader always checks thatissueis a positive whole number (TOML'strueis rejected). On every run, the guard looks up every section's issue withgh api, through the PR description check's lookup, and fails unless it is an open issue (not a pull request) inissue_repository.GH_TOKENnorGITHUB_TOKENis set, when a lookup fails, when gh can't run, and whenissue_repositoryisn't the repository the workflow runs in (GITHUB_REPOSITORY).scripts/run_quality_guards.pynow prints the rules guard's notes, as it already printed the ci-scripts guard's. With a token, it behaves as in CI.quality-guardsjob in.github/workflows/pr.ymlnow haspermissions: contents: read, issues: read. Only its "Run quality guards" step getsGH_TOKEN: ${{ github.token }}.docs/engineering/skills/type-checking.md(the guard's checks, the "It doesn't check" list and the paragraph after it) anddocs/engineering/skills/rules-registry.md(the[rule.downgrade]row, the Baselines bullets, and the commands paragraph) describe what the guard now checks, and what it still doesn't.The registry has no
[rule.downgrade]section on this branch,pyright-apis-integormain, so nothing in the registry changes status. The only registry change is the new top-levelissue_repository = "PolicyEngine/policyengine-sim-api".python3 -m scripts.rules renderleavesdocs/generated/rules.mdandAGENTS.mdunchanged.Design choices
[rule.downgrade]keepsreasonand gainsissue, the name[[rule.exception]]already uses for its tracking issue. The parsed section isDowngrade(reason, issue), which replacesRule.downgrade_reason. A section without both is reported, and read as absent, so it never allows a weaker rule. Unknown keys in it are now reported.projects; the schema rejects that field on every other kind. A package that moves between two enforced rules keeps its check only if both run the same checker, so a comparison across kinds would be wrong anyway.[rule.downgrade]sections are looked up: all of them, on every run, not only those this pull request adds. The invariant is then "each downgrade names an open issue". The cost is that closing the issue while its section is still in the registry fails the guard on every pull request, as an expiredreview_bydate does. So that restoring a rule doesn't cause this, the schema now rejects[rule.downgrade]on an enforced rule: the pull request that restores the rule must remove the section, and can then close the issue.issue_repositorykey means local runs and CI look issues up in the same repository; the existingrepokey holds only the name. On GitHub Actions it must match the runner'sGITHUB_REPOSITORY, which a pull request can't change.check_pr_description.py's lookup raisedDescriptionErrorwith messages that end in how to fix the description.require_open_issuenow raisesNotAnOpenIssueError(missing, moved or closed issue, or a pull request) orIssueLookupError(GitHub couldn't be asked, or its answer wasn't an issue), with messages that say only what is wrong.main()adds the same fixes as before. The guard loads the script by path and adds its own fixes. The PR description check's messages and exit codes are unchanged, except that the two lookup failures that said only "Re-run this job." now also say to check the job'sGH_TOKEN, as the third already did.GH_TOKENorGITHUB_TOKENas a token, since gh reads both. With neither, outside GitHub Actions, it skips the lookup and adds a note per issue:rule <id>: tracking issue #<n> was not checked, because neither GH_TOKEN nor GITHUB_TOKEN is set. CI checks it; to check it here, set one, for example GH_TOKEN="$(gh auth token)". With a token, a lookup that fails locally fails the guard, as in CI.Commits
Each commit's tests were written first and run against the old code, or, where the module didn't exist yet, against a stub that returns no findings, so each test fails on its own assertion. The rules tool's tests:
python3 -m unittest discover -s scripts/rules/tests -t ..f731679b). Red:test_loadcouldn't importDowngrade; a reason-only downgrade still passed (test_downgrade_needs_reason_and_issue); the status fix text was the old one. That is 1 failure and 2 errors, out of 188 tests. Green: 195 tests pass.608c6e45). Red, against a stub: 5 tests failed (6 failures; one test covers both ratcheted and advisory), and the 5 tests of allowed cases passed. Green: 205 tests pass.b0bd4f89). Red: 2 failures; a new ratcheted rule with 2 tolerated violations passed without a downgrade, and no note named the downgrade. The count-0 test passed. Green: 208 tests pass.a0882ae7). Red: 8 new in-process tests ofrequire_open_issuefailed withAttributeError, and the 52 existing PR-description tests passed. Green: 60 pass; the whole simulation-entry suite, 738 tests, passes.8ae08ffc). Red, against a stub: 17 tests, 22 failures counting subtests. Most failed first on the loader rejectingissue_repositoryas an unknown key. A second red: a lookup script withoutrequire_open_issuecrashed the guard withAttributeError. Green: 227 tests pass.94d57f7f). Red: 2 failures; the job had nopermissions(KeyError), and no step gotGH_TOKEN. Green: both pass, along with the 222 PR-description, workflow-script and deployment-asset tests, and the 10 stacked-CI tests.Commits from the self-review:
29467523). Red: 2 failures; a package moving from a ratcheted rule to an advisory rule passed, and so did a package from a ratcheted rule that no rule lists. Green: 230 tests pass.201a2c90). Red: 1 subtest failure,issue = true. Green: 231 tests pass.67a51051). Red: 1 failure; with onlyGITHUB_TOKENset, the fix said to checkGH_TOKEN. Green: 232 tests pass.4e481bd5). Red: with a token set and a gh on PATH that records its calls,RegistrySchemaTestrangh api repos/example/repo/issues/912. Green: the same run records no call, and the whole suite run that way records none.90e77bf5). Red: 1 failure; withGITHUB_REPOSITORY=example/other, the guard still looked the issue up in the registry'sexample/repoand passed. Green: 234 tests pass, both locally and withGITHUB_ACTIONS=true,GITHUB_REPOSITORYset and no token.795c73af). Docs and one test comment only; 234 tests pass.Suppressions added
None. The diff adds no
# noqa,# type: ignoreor# pyright: ignore. Where ruff objected, the code changed instead:run_guard's base comparison moved into_compare_with_base(C901, PLR0912), a constant was renamed (S105), and an annotation-only import moved underTYPE_CHECKING(TC001).Real bugs found
[[rule.ci]], and then leave thepyrightjob's matrix, with only notes. Fixed in commit 7.[[rule.exception]] issue = truewas accepted as issue 1 (this predates the branch). Fixed in commit 8.example/repo, which the test then ignored. Fixed in commit 10.Self-review
Self-review, not a review disposition. Two rounds, using the
review-codeformat: the first by the agent that wrote the change, the second by a separate reviewer that was given the code and the plan but not the first round's findings.Round 1 found:
issue = truewas accepted (commit 8).GH_TOKEN(commit 9).Round 2 found:
issue_repositorycame from the head registry (commit 11).[rule.downgrade]from a rule that stays weakened passes;Protocol.scripts/rulesisn't type-checked, and the tests run against the real module, so a rename fails them.test_pyright.PyrightCommandTest.test_list_packages_takes_no_other_pyright_optionsfails, because 3.11's argparse rejects the arguments before the command's own check runs. This branch doesn't touch that test orcli; under 3.13 and 3.14 all tests pass.Round 3, by the same separate reviewer, covered commits 10–12 (
67a51051..795c73af) and found no substantiated bugs. One minor note, not changed: a workflow that runs inside a fork, rather than a pull request from a fork into this repository, would fail whenever the registry has a[rule.downgrade], because itsGITHUB_REPOSITORYnames the fork. This repository runs CI only for same-repository branches, and forks have Actions turned off by default.Gaps the guard still has, now listed in the guides:
[rule.downgrade]section is new, or was removed from a rule that stays weakened;Requiring a section to stay while its rule stays weakened would make every downgrade permanent-until-restored, so it is left as a decision for a person.
Environment variables
GH_TOKENscripts/rules/tracking_issues.pythroughcheck_pr_description.require_open_issue; the guard checks it is setgithub.token, the job's automatic token, limited by thequality-guardsjob's newpermissions: contents: read, issues: readenv:on "Run quality guards".run_quality_guards.pyruns the rules guard in its own process, soos.environhas it, andsubprocess.run(["gh", ...])passes noenv=, so gh inherits it. The earlier "Test the rules registry tool" step doesn't get it.GITHUB_TOKEN(environment variable)GITHUB_ACTIONStracking_issues.check_tracking_issues(and the existing base-ref handling inguard.py)truein every stepGITHUB_REPOSITORYtracking_issues.check_tracking_issues(new);check_pr_description.main()(unchanged, only when run as the PR description job's script)GITHUB_BASE_REFbaseline.default_base_ref(unchanged)pull_requesteventsPATH(forgh)subprocess.run(["gh", ...])ubuntu-latestimagePR descriptionjob already relies on it, and passed on this PRThe tests set their own
FAKE_GH_CALLS,FAKE_GH_ANSWERSandFAKE_CALLS_LOG, and clearGH_TOKEN,GITHUB_TOKEN,GITHUB_ACTIONSandGITHUB_REPOSITORYwhere they matter.Not verified in CI:
pr.yml's jobs are skipped here (CI completefails by design).quality-guardswill first run with these changes once Fix apis-integ's Pyright errors and enforce every Pyright rule #877 merges.[rule.downgrade]section, that run won't call the lookup, so the token's live use is unexercised.permissionsdrop every scope butcontentsandissues. Other jobs here already runactions/checkout,setup-python@v6andsetup-uv@v8.1.0with onlycontents: read(publish-clients.yml,simulation-deploy.reusable.yml), so I expect this job to work, but it hasn't run yet.Testing
Run before each push, from the repository root:
python3 -m unittest discover -s scripts/rules/tests -t .: 234 tests pass. They also pass withGITHUB_ACTIONS=true,GITHUB_REPOSITORYset and no token, as in the CI step, and with tokens set and a recording gh first on PATH, which records no call.python3 scripts/run_quality_guards.py: every guard passes.python3 -m scripts.rules guard --base-ref origin/pyright-apis-integand--base-ref origin/mainpass.uv tool run ruff@0.15.22, because the local ruff is 0.16.8):format --check projects/*/src libs/*/srcandcheck .pass, and so doesformat --check scripts .github/scripts.python3 -m scripts.rules pyright --sync: every package passes with no Pyright errors. Pyright doesn't checkscripts/or.github/scripts/, so its results can't change here. For4e481bd5, a test-only change, I ran Pyright straight after pushing rather than before.uv run --frozen pytest:tests/test_pr_description_check.py(60) andtests/test_quality_guards_workflow.py(2) pass. The whole suite (738) passed after commit 4. simulation-executor'stests/test_ci_complete_gate.py(10) passes.🤖 Generated with Claude Code