Skip to content

Commit eefe53a

Browse files
authored
Merge pull request #913 from PolicyEngine/rule-weakening-guard
Catch packages leaving an enforced rule and new ratcheted rules, and give each downgrade an open tracking issue
2 parents c6da1a1 + 66a940b commit eefe53a

16 files changed

Lines changed: 1723 additions & 139 deletions

File tree

‎.github/scripts/check_pr_description.py‎

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
the line isn't what the author meant.
2424
- An empty or missing (``null``) description fails.
2525
- The number is ASCII digits without a leading zero.
26+
27+
``require_open_issue`` is the issue lookup on its own. The rules guard
28+
(``scripts/rules/tracking_issues.py``) loads this file to check that each
29+
``[rule.downgrade]`` names an open issue. Its errors say only what is wrong, and
30+
each caller adds its own fix.
2631
"""
2732

2833
from __future__ import annotations
@@ -41,6 +46,14 @@
4146
"description runs this check again. See docs/engineering/skills/github-prs.md "
4247
f"(rule {RULE_ID})."
4348
)
49+
RETRY_LOOKUP = (
50+
"Re-run this job; if it fails again, check the GH_TOKEN the workflow gives it."
51+
)
52+
REOPEN_ISSUE = (
53+
"If this pull request still fixes it, reopen it and re-run this job; "
54+
"otherwise open an issue for this work and edit the first line to name it "
55+
f"(rule {RULE_ID})."
56+
)
4457
FIXES_LINE = re.compile(r"Fixes #([1-9][0-9]*)")
4558
# Markdown line endings: CRLF, a carriage return alone, or a line feed.
4659
LINE_ENDING = re.compile(r"\r\n|\r|\n")
@@ -55,7 +68,29 @@
5568

5669

5770
class DescriptionError(Exception):
58-
"""The description, or the issue it names, does not satisfy the rule."""
71+
"""The description's first line does not name an issue as the rule requires."""
72+
73+
74+
class IssueLookupError(Exception):
75+
"""GitHub couldn't be asked about an issue, or its answer wasn't an issue.
76+
77+
Running the lookup again may succeed. The message says what went wrong,
78+
without a fix.
79+
"""
80+
81+
82+
class NotAnOpenIssueError(Exception):
83+
"""A number names no open issue in the repository.
84+
85+
``state`` is the issue's state, such as ``closed``, when the number names an
86+
issue in the repository that isn't open, and None when it names no issue
87+
there. The message says what the number names, without a fix.
88+
"""
89+
90+
def __init__(self, message: str, *, state: str | None = None) -> None:
91+
"""Keep ``message`` as the error's text, and the issue's ``state``."""
92+
super().__init__(message)
93+
self.state = state
5994

6095

6196
def _shown(line: str) -> str:
@@ -102,7 +137,9 @@ def fetch_issue(repository: str, number: int) -> dict[str, object]:
102137
103138
The issues API also answers for pull requests, and gh follows the redirect
104139
GitHub sends for an issue transferred to another repository, so the answer
105-
still has to be checked with ``check_issue``.
140+
still has to be checked with ``check_issue``. Raise ``NotAnOpenIssueError``
141+
when GitHub has no issue ``number``, and ``IssueLookupError`` when the
142+
lookup fails.
106143
"""
107144
completed = subprocess.run(
108145
["gh", "api", f"repos/{repository}/issues/{number}"],
@@ -112,21 +149,18 @@ def fetch_issue(repository: str, number: int) -> dict[str, object]:
112149
)
113150
if completed.returncode != 0:
114151
if MISSING_ISSUE.search(completed.stderr):
115-
msg = f"There is no issue #{number} in {repository}. {HOW_TO_FIX}"
116-
raise DescriptionError(msg)
152+
msg = f"There is no issue #{number} in {repository}."
153+
raise NotAnOpenIssueError(msg)
117154
reported = completed.stderr.strip() or f"exit status {completed.returncode}"
118-
msg = (
119-
f"Could not look up #{number} in {repository}: {reported}. Re-run this "
120-
"job; if it fails again, check the GH_TOKEN the workflow gives it."
121-
)
122-
raise DescriptionError(msg)
155+
msg = f"Could not look up #{number} in {repository}: {reported}."
156+
raise IssueLookupError(msg)
123157
try:
124158
issue = json.loads(completed.stdout)
125159
except json.JSONDecodeError:
126160
issue = None
127161
if not isinstance(issue, dict):
128-
msg = f"gh api did not print an issue for #{number}. Re-run this job."
129-
raise DescriptionError(msg)
162+
msg = f"gh api did not print an issue for #{number}."
163+
raise IssueLookupError(msg)
130164
return issue
131165

132166

@@ -139,30 +173,41 @@ def _repository_of(repository_url: object) -> str | None:
139173

140174

141175
def check_issue(repository: str, number: int, issue: dict[str, object]) -> None:
142-
"""Raise ``DescriptionError`` unless ``issue`` is open issue ``number`` here."""
176+
"""Return quietly if ``issue``, GitHub's answer for ``number``, is open here.
177+
178+
Raise ``NotAnOpenIssueError`` when it is a pull request, an issue moved to
179+
another repository or an issue that isn't open, and ``IssueLookupError``
180+
when the answer names no repository.
181+
"""
143182
issue_repository = _repository_of(issue.get("repository_url"))
144183
if issue_repository is None:
145184
msg = (
146185
f"GitHub's answer for #{number} names no repository, so it can't be "
147-
f"confirmed as an issue in {repository}. Re-run this job."
186+
f"confirmed as an issue in {repository}."
148187
)
149-
raise DescriptionError(msg)
188+
raise IssueLookupError(msg)
150189
if issue_repository.lower() != repository.lower() or issue.get("number") != number:
151190
msg = (
152191
f"#{number} is not an issue in {repository}: it moved to "
153-
f"{issue_repository}#{issue.get('number')}. {HOW_TO_FIX}"
192+
f"{issue_repository}#{issue.get('number')}."
154193
)
155-
raise DescriptionError(msg)
194+
raise NotAnOpenIssueError(msg)
156195
if issue.get("pull_request") is not None:
157-
msg = f"#{number} is a pull request, not an issue. {HOW_TO_FIX}"
158-
raise DescriptionError(msg)
159-
if issue.get("state") != "open":
160-
msg = (
161-
f"Issue #{number} is {issue.get('state')}. If this pull request still "
162-
"fixes it, reopen it and re-run this job; otherwise open an issue for "
163-
f"this work and edit the first line to name it (rule {RULE_ID})."
164-
)
165-
raise DescriptionError(msg)
196+
msg = f"#{number} is a pull request, not an issue."
197+
raise NotAnOpenIssueError(msg)
198+
state = issue.get("state")
199+
if state != "open":
200+
msg = f"Issue #{number} is {state}."
201+
raise NotAnOpenIssueError(msg, state=str(state))
202+
203+
204+
def require_open_issue(repository: str, number: int) -> None:
205+
"""Return quietly if ``number`` is an open issue, not a pull request, here.
206+
207+
Raise ``NotAnOpenIssueError`` or ``IssueLookupError`` otherwise; see
208+
``fetch_issue`` and ``check_issue``.
209+
"""
210+
check_issue(repository, number, fetch_issue(repository, number))
166211

167212

168213
def _required_environment(name: str) -> str:
@@ -198,15 +243,21 @@ def main() -> int:
198243
description = _description_from_event(_required_environment("GITHUB_EVENT_PATH"))
199244
try:
200245
number = issue_number_from_description(description)
201-
check_issue(repository, number, fetch_issue(repository, number))
246+
require_open_issue(repository, number)
202247
except DescriptionError as error:
203-
print(f"::error title={ANNOTATION_TITLE}::{_annotation_text(str(error))}")
204-
return 1
205-
print(
206-
f"The first line is Fixes #{number}, and #{number} is an open issue in "
207-
f"{repository}."
208-
)
209-
return 0
248+
message = str(error)
249+
except IssueLookupError as error:
250+
message = f"{error} {RETRY_LOOKUP}"
251+
except NotAnOpenIssueError as error:
252+
message = f"{error} {HOW_TO_FIX if error.state is None else REOPEN_ISSUE}"
253+
else:
254+
print(
255+
f"The first line is Fixes #{number}, and #{number} is an open issue in "
256+
f"{repository}."
257+
)
258+
return 0
259+
print(f"::error title={ANNOTATION_TITLE}::{_annotation_text(message)}")
260+
return 1
210261

211262

212263
if __name__ == "__main__":

‎.github/workflows/pr.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ jobs:
107107
# Full CI runs only on the lowest unmerged pull request in a stack.
108108
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
109109
runs-on: ubuntu-latest
110+
# The checkout reads the repository, and the rules guard looks up the
111+
# tracking issue of each [rule.downgrade] with the job's token.
112+
permissions:
113+
contents: read
114+
issues: read
110115

111116
steps:
112117
- uses: actions/checkout@v6
@@ -129,6 +134,10 @@ jobs:
129134
run: pip install "shellcheck-py==0.11.0.1"
130135

131136
- name: Run quality guards
137+
env:
138+
# Read by gh, which the rules guard runs to confirm that each
139+
# [rule.downgrade] names an open issue; without it the guard fails.
140+
GH_TOKEN: ${{ github.token }}
132141
run: python scripts/run_quality_guards.py
133142

134143
pyright:

‎docs/engineering/skills/rules-registry.md‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ python -m unittest discover -s scripts/rules/tests -t . # the tool's
2121
```
2222

2323
All commands use only the standard library, so they run without installing the
24-
project.
24+
project. When a rule has a `[rule.downgrade]` section, `guard` also runs
25+
`gh api` to look up its tracking issue; see [Baselines](#baselines).
2526

2627
## Adding or changing a rule
2728

@@ -58,7 +59,7 @@ gap is visible, rather than leaving it out.
5859
| `[rule.proof]` | Optional; see "Proof". |
5960
| `[rule.baseline]` | Only for `ratcheted` rules: `file` listing tolerated violations, and their `count`. |
6061
| `[[rule.exception]]` | Temporary carve-out: `path`, `reason`, `expires`. |
61-
| `[rule.downgrade]` | Required `reason` when a rule moves from enforced/ratcheted to advisory/doc-only, or from enforced to ratcheted. |
62+
| `[rule.downgrade]` | `reason`, and `issue`: the number of an open issue in the registry's `issue_repository` that tracks restoring the rule. Not allowed on an enforced rule. Required when a rule moves from enforced/ratcheted to advisory/doc-only, or from enforced to ratcheted; on a new rule that starts ratcheted with a baseline count above 0; and on a rule that now lists a package from a stronger rule on the base branch. |
6263

6364
`blocking` is derived, never declared: a rule blocks merges when its status is
6465
`enforced` or `ratcheted` and its tier is not `judgment`.
@@ -124,11 +125,39 @@ fail the guard yet.
124125
Outside the files that allow file-level exceptions, a justified exception is
125126
an inline `# noqa: CODE # reason`.
126127
- Other enforcers (mypy, Pyright, Import Linter, custom guards) may start
127-
`ratcheted` with a baseline file of tolerated violations. Baselines may only
128-
shrink: the guard compares counts against the base branch.
128+
`ratcheted` with a baseline file of tolerated violations. A new rule that
129+
starts ratcheted with a count above 0 needs a `[rule.downgrade]` section,
130+
as a downgrade does. Baselines may only shrink: the guard compares counts
131+
against the base branch.
129132
- Moving a rule from enforced/ratcheted to advisory/doc-only, or from enforced
130-
to ratcheted, needs an explicit `[rule.downgrade] reason`. The guard accepts
131-
any non-empty reason; the reason is for the reviewer.
133+
to ratcheted, needs a `[rule.downgrade]` section with a `reason` and an
134+
`issue`.
135+
- A `[rule.downgrade]` section's `issue` is an open issue in
136+
`issue_repository`, set at the top of the registry, that tracks restoring
137+
the rule. The guard looks up every section's issue on every run, with
138+
`gh api` and the token in `GH_TOKEN` or `GITHUB_TOKEN`. On GitHub Actions, a
139+
missing token or a failed lookup fails it, and so does an
140+
`issue_repository` other than the repository the workflow runs in
141+
(`GITHUB_REPOSITORY`), so a pull request can't name an issue elsewhere.
142+
Elsewhere, without a token, it prints a note for each issue it didn't
143+
check. Closing the issue while the section is still in the registry fails
144+
the guard on every pull request, and an enforced rule can't have the
145+
section, so the pull request that restores the rule removes it. The guard
146+
accepts any non-empty reason and any open issue; whether the issue is about
147+
this rule, and whether a section was removed from a rule that stays
148+
weakened, is for the reviewer.
149+
- A package in an enforced or ratcheted rule's `enforcer.projects` on the base
150+
branch (only `pyright` enforcers list packages) stays, while its
151+
`pyproject.toml` exists, in a rule of the same kind that is at least as
152+
strong: enforced for a package from an enforced rule, enforced or
153+
ratcheted for one from a ratcheted rule. If it moves to a weaker rule, that
154+
rule needs a `[rule.downgrade]` section. Deleting the package, or moving it
155+
to a rule at least as strong, passes this check; a new ratcheted rule with a
156+
count above 0 still needs the section.
157+
- The guard compares rules by id. Removing a rule, or replacing it with a
158+
weaker rule under a new id, produces only the notes "rule ... was removed"
159+
and "rule ... is new", except that the checks above still cover Pyright
160+
packages and new ratcheted rules. Review catches the rest.
132161
- **Weakening a rule is a decision for a person, not an agent.** Agents don't
133162
downgrade a rule, add a `[rule.downgrade]` section, create a baseline or add
134163
entries to one, or move a package or path out of a rule's scope. When a rule

‎docs/engineering/skills/type-checking.md‎

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,29 +111,59 @@ After fixing errors:
111111
2. set the rule's `[rule.baseline] count` in `rules/registry.toml` to the
112112
number of entries left.
113113

114-
What stops a baseline growing is the rules guard, which compares each existing
115-
rule's status and baseline count with the base branch. It fails when the count
116-
doesn't match the file, when the count is higher than on the base branch, and
117-
when an enforced rule becomes ratcheted without a `[rule.downgrade] reason`.
114+
What stops a baseline growing is the rules guard, which compares the registry
115+
with the base branch. It fails when:
116+
117+
- a rule's baseline count doesn't match its file, or is higher than on the
118+
base branch;
119+
- an enforced rule becomes ratcheted without a `[rule.downgrade]` section,
120+
which gives a `reason` and an `issue`, the number of the issue that tracks
121+
restoring the rule;
122+
- a rule that the base branch doesn't have starts ratcheted with a baseline
123+
count above 0, without a `[rule.downgrade]` section;
124+
- a package that an enforced or ratcheted Pyright rule lists on the base
125+
branch, and whose `pyproject.toml` still exists, is listed by no Pyright
126+
rule, or moves to a weaker one (from enforced to any other status, or from
127+
ratcheted to advisory), unless the rule that lists it now has a
128+
`[rule.downgrade]` section. Deleting the package, or moving it to a rule at
129+
least as strong, passes this check; a new ratcheted rule with a count above
130+
0 still needs the section, as above;
131+
- a `[rule.downgrade]` section's `issue` isn't an open issue in this
132+
repository. The guard looks up every section's issue on every run, with
133+
`gh api` and the token in `GH_TOKEN` or `GITHUB_TOKEN`. In CI, a missing
134+
token or a failed lookup fails it, and so does an `issue_repository` at the
135+
top of the registry that isn't the repository the workflow runs in. Locally,
136+
without a token, it prints a note for each issue it didn't check; to check
137+
them, run it with `GH_TOKEN="$(gh auth token)"`.
138+
118139
It doesn't check:
119140

120-
- what the reason says: any non-empty reason passes;
141+
- what the reason says, or whether the issue is about restoring this rule: any
142+
non-empty reason and any open issue pass;
121143
- which entries changed: a change that fixes one error and adds another keeps
122144
the count and passes;
123-
- which packages a rule lists: moving a package from an enforced rule into a
124-
new ratcheted rule passes without a reason, because a new rule has no status
125-
on the base branch to compare with.
145+
- whether a `[rule.downgrade]` section is new: a section already on the base
146+
branch also covers a further weakening of its rule, such as another package
147+
moving into it;
148+
- whether a rule that stays weakened keeps its `[rule.downgrade]` section:
149+
removing the section from a ratcheted or advisory rule passes, and its
150+
tracking issue can then be closed without failing anything.
126151

127152
Review is what catches these. `.github/CODEOWNERS` assigns `rules/` to the core
128153
developers, but `sim.process.rule-machinery-code-owned` is doc-only: merging
129154
doesn't require their approval. Whoever reviews a pull request that changes a
130-
baseline, a rule's status or a rule's `enforcer.projects` checks it.
131-
132-
When a person decides to ratchet a package's rule, `--write-baseline` creates
133-
its baseline: it refuses an error an existing baseline doesn't list, but
134-
records every current error when the file doesn't exist yet.
135-
136-
Once a baseline is empty, delete it and make the rule `enforced`.
155+
baseline, a rule's status, a rule's `enforcer.projects` or a
156+
`[rule.downgrade]` section checks it.
157+
158+
When a person decides to ratchet a package's rule, they add the
159+
`[rule.downgrade]` section, and `--write-baseline` creates its baseline: it
160+
refuses an error an existing baseline doesn't list, but records every current
161+
error when the file doesn't exist yet.
162+
163+
Once a baseline is empty, delete it, make the rule `enforced` and remove its
164+
`[rule.downgrade]` section, which an enforced rule can't have. That pull
165+
request can then close the tracking issue. Closing the issue while the
166+
section is still in the registry fails the guard on every pull request.
137167

138168
## Fixing errors
139169

0 commit comments

Comments
 (0)