claude-dev-env 8.6.1 → 8.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,241 @@
1
+ """Constants for the review_closure script.
2
+
3
+ Per the project's configuration conventions, script-level scalar constants
4
+ live in dev_env_scripts_constants alongside timing.py.
5
+ """
6
+
7
+ GITHUB_API_ROOT: str = "https://api.github.com"
8
+ """Root of the GitHub REST API."""
9
+
10
+ GITHUB_GRAPHQL_ENDPOINT: str = "https://api.github.com/graphql"
11
+ """Endpoint that answers the review-thread query."""
12
+
13
+ PULL_REQUEST_ENDPOINT_TEMPLATE: str = "{api_root}/repos/{slug}/pulls/{number}"
14
+ """REST route carrying one pull request."""
15
+
16
+ REVIEW_THREADS_ENDPOINT_TEMPLATE: str = (
17
+ "{api_root}/repos/{slug}/pulls/{number}/ccr/review_threads"
18
+ )
19
+ """REST route a Claude Code session has for review threads."""
20
+
21
+ CHECK_RUNS_ENDPOINT_TEMPLATE: str = (
22
+ "{api_root}/repos/{slug}/commits/{sha}/check-runs?per_page={page_size}"
23
+ )
24
+ """REST route carrying the check runs reported on one commit."""
25
+
26
+ REVIEW_THREAD_PAGE_SIZE: int = 100
27
+ """How many review threads and comments one query asks for."""
28
+
29
+ CHECK_RUN_PAGE_SIZE: int = 100
30
+ """How many check runs one request asks for."""
31
+
32
+ REVIEW_THREAD_QUERY: str = """
33
+ query($owner: String!, $name: String!, $number: Int!, $pageSize: Int!) {
34
+ repository(owner: $owner, name: $name) {
35
+ pullRequest(number: $number) {
36
+ reviewThreads(first: $pageSize) {
37
+ nodes {
38
+ path
39
+ isResolved
40
+ isOutdated
41
+ comments(first: $pageSize) {
42
+ nodes {
43
+ body
44
+ author { login }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ """
53
+ """GraphQL query for the review threads on one pull request."""
54
+
55
+ ALL_THREAD_NODE_KEYS: tuple[str, ...] = (
56
+ "data",
57
+ "repository",
58
+ "pullRequest",
59
+ "reviewThreads",
60
+ "nodes",
61
+ )
62
+ """Path from the GraphQL answer down to the review-thread list."""
63
+
64
+ ALL_RESOLVED_KEYS: tuple[str, ...] = ("isResolved", "resolved", "is_resolved")
65
+ """Field names either route uses for a resolved review thread."""
66
+
67
+ ALL_OUTDATED_KEYS: tuple[str, ...] = ("isOutdated", "outdated", "is_outdated")
68
+ """Field names either route uses for a thread whose code has been replaced."""
69
+
70
+ ALL_COMMENT_LIST_KEYS: tuple[str, ...] = ("comments", "all_comments")
71
+ """Field names either route uses for a thread's comment list."""
72
+
73
+ COMMENT_NODES_KEY: str = "nodes"
74
+ """GraphQL wraps a comment list in this field."""
75
+
76
+ COMMENT_BODY_KEY: str = "body"
77
+ """Field carrying a comment's text."""
78
+
79
+ ALL_COMMENT_AUTHOR_KEYS: tuple[str, ...] = ("author", "user")
80
+ """Field names either route uses for a comment's author."""
81
+
82
+ AUTHOR_LOGIN_KEY: str = "login"
83
+ """Field carrying an author's GitHub login."""
84
+
85
+ THREAD_PATH_KEY: str = "path"
86
+ """Field carrying the file a review thread points at."""
87
+
88
+ UNNAMED_THREAD_SUBJECT: str = "a review thread"
89
+ """Subject text for a thread that names no file."""
90
+
91
+ RED_CIRCLE_MARKER: str = "\U0001f534"
92
+ """The red circle a review marks a blocking finding with."""
93
+
94
+ APPROVALS_CHECK_NAME: str = "Claude Approvals"
95
+ """Name of the check run whose rows carry blocking findings."""
96
+
97
+ ALL_BLOCKING_CONCLUSIONS: tuple[str, ...] = ("failure", "action_required")
98
+ """Check-run conclusions that mean a blocking row is open on this head."""
99
+
100
+ APPROVALS_OPEN_REASON: str = (
101
+ "the Claude Approvals check reports a blocking row on this commit"
102
+ )
103
+ """Why a failing approvals check leaves the pull request open."""
104
+
105
+ RED_CIRCLE_OPEN_REASON: str = (
106
+ "a blocking finding here carries no reply from the driving agent"
107
+ )
108
+ """Why a red-circle thread stays open until the agent answers it."""
109
+
110
+ UNANSWERED_OPEN_REASON: str = "unresolved, with no reply from the driving agent"
111
+ """Why an ordinary thread stays open."""
112
+
113
+ CHECK_RUNS_KEY: str = "check_runs"
114
+ """Field carrying the check-run list in the REST answer."""
115
+
116
+ CHECK_RUN_NAME_KEY: str = "name"
117
+ """Field carrying a check run's name."""
118
+
119
+ CHECK_RUN_CONCLUSION_KEY: str = "conclusion"
120
+ """Field carrying a check run's conclusion."""
121
+
122
+ HEAD_KEY: str = "head"
123
+ """Field carrying a pull request's head."""
124
+
125
+ SHA_KEY: str = "sha"
126
+ """Field carrying a commit identifier."""
127
+
128
+ NUMBER_KEY: str = "number"
129
+ """Field carrying a pull request number."""
130
+
131
+ USER_KEY: str = "user"
132
+ """Field carrying the account that opened a pull request."""
133
+
134
+ SHORT_SHA_LENGTH: int = 7
135
+ """How much of a commit identifier the verdict line prints."""
136
+
137
+ SLUG_SEPARATOR: str = "/"
138
+ """Separator between owner and name in a repository slug."""
139
+
140
+ QUERY_KEY: str = "query"
141
+ """GraphQL payload field carrying the query text."""
142
+
143
+ VARIABLES_KEY: str = "variables"
144
+ """GraphQL payload field carrying the query variables."""
145
+
146
+ OWNER_VARIABLE: str = "owner"
147
+ """GraphQL variable naming the repository owner."""
148
+
149
+ NAME_VARIABLE: str = "name"
150
+ """GraphQL variable naming the repository."""
151
+
152
+ NUMBER_VARIABLE: str = "number"
153
+ """GraphQL variable naming the pull request number."""
154
+
155
+ PAGE_SIZE_VARIABLE: str = "pageSize"
156
+ """GraphQL variable naming the page size."""
157
+
158
+ ALL_TOKEN_ENVIRONMENT_VARIABLES: tuple[str, ...] = (
159
+ "GITHUB_TOKEN",
160
+ "GH_TOKEN",
161
+ )
162
+ """Environment variables that carry a GitHub token, in the order read."""
163
+
164
+ NO_SIGN_IN_MESSAGE: str = (
165
+ "No GitHub token found. Set GITHUB_TOKEN or GH_TOKEN and run this again."
166
+ )
167
+ """What to print when no token is available."""
168
+
169
+ COMMAND_DESCRIPTION: str = (
170
+ "Report whether every review finding on a pull request head has been "
171
+ "answered by the agent driving it."
172
+ )
173
+ """Help text for the command."""
174
+
175
+ SLUG_ARGUMENT_HELP: str = "Repository as owner/name."
176
+ """Help text for the repository argument."""
177
+
178
+ NUMBER_ARGUMENT_HELP: str = "Pull request number."
179
+ """Help text for the pull request argument."""
180
+
181
+ DRIVER_LOGIN_ARGUMENT_HELP: str = (
182
+ "A login whose comments count as the driving agent's reply. Repeatable. "
183
+ "The account that opened the pull request always counts."
184
+ )
185
+ """Help text for the driver login argument."""
186
+
187
+ CLOSED_VERDICT_LABEL: str = "CLOSED"
188
+ """Verdict label for a pull request with every finding answered."""
189
+
190
+ OPEN_VERDICT_LABEL: str = "OPEN"
191
+ """Verdict label for a pull request with a finding still waiting."""
192
+
193
+ CLOSED_DETAIL: str = "every review finding on this commit is answered"
194
+ """Detail text on a closed verdict."""
195
+
196
+ OPEN_DETAIL_TEMPLATE: str = "{count} review finding(s) wait on the driving agent"
197
+ """Detail text on an open verdict."""
198
+
199
+ VERDICT_LINE_TEMPLATE: str = "{label} {slug}#{number} {sha} :: {detail}"
200
+ """Shape of the one verdict line this command prints."""
201
+
202
+ FINDING_LINE_TEMPLATE: str = " - {subject}: {reason}"
203
+ """Shape of each open-finding line printed under the verdict."""
204
+
205
+ CLOSED_EXIT_CODE: int = 0
206
+ """Exit status when every finding is answered."""
207
+
208
+ OPEN_EXIT_CODE: int = 1
209
+ """Exit status when a finding waits on the driving agent."""
210
+
211
+ ERROR_EXIT_CODE: int = 2
212
+ """Exit status when the pull request state could not be read."""
213
+
214
+ REQUEST_TIMEOUT_SECONDS: int = 30
215
+ """How long one GitHub request waits for an answer."""
216
+
217
+ REQUEST_FAILED_TEMPLATE: str = "{url} answered {status}"
218
+ """What to report when GitHub answers with something other than success."""
219
+
220
+ GET_METHOD: str = "GET"
221
+ """HTTP method for a read."""
222
+
223
+ POST_METHOD: str = "POST"
224
+ """HTTP method the GraphQL query uses."""
225
+
226
+ COMMENT_IDS_KEY: str = "comment_ids"
227
+ """Field the session route uses for a thread's comment identifiers."""
228
+
229
+ COMMENT_IDENTIFIER_KEY: str = "id"
230
+ """Field carrying a review comment's identifier."""
231
+
232
+ REVIEW_COMMENTS_ENDPOINT_TEMPLATE: str = (
233
+ "{api_root}/repos/{slug}/pulls/{number}/comments?per_page={page_size}&page={page}"
234
+ )
235
+ """REST route carrying one page of a pull request's review comments."""
236
+
237
+ REVIEW_COMMENT_PAGE_SIZE: int = 100
238
+ """How many review comments one page carries."""
239
+
240
+ MAX_REVIEW_COMMENT_PAGES: int = 20
241
+ """How many comment pages one run reads before it stops."""
@@ -0,0 +1,21 @@
1
+ """The second-account thresholds keep the main account the protected one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dev_env_scripts_constants.claude_account_constants import (
6
+ LAUNCHER_TEXT_TEMPLATE,
7
+ MAIN_SESSION_USED_CEILING_PERCENT,
8
+ MAIN_WEEKLY_USED_CEILING_PERCENT,
9
+ SECOND_SESSION_USED_CEILING_PERCENT,
10
+ SECOND_WEEKLY_USED_CEILING_PERCENT,
11
+ )
12
+
13
+
14
+ def test_main_ceilings_sit_below_the_second_account_ceilings() -> None:
15
+ assert MAIN_WEEKLY_USED_CEILING_PERCENT < SECOND_WEEKLY_USED_CEILING_PERCENT
16
+ assert MAIN_SESSION_USED_CEILING_PERCENT < SECOND_SESSION_USED_CEILING_PERCENT
17
+
18
+
19
+ def test_launcher_names_the_profile_and_passes_every_argument() -> None:
20
+ assert "{profile_home}" in LAUNCHER_TEXT_TEMPLATE
21
+ assert "claude %*" in LAUNCHER_TEXT_TEMPLATE
@@ -0,0 +1,12 @@
1
+ """Behavioral tests for the host-aware code-review invoker constants module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dev_env_scripts_constants import code_review_constants as review_constants
6
+
7
+
8
+ def test_default_effort_is_a_known_token() -> None:
9
+ assert (
10
+ review_constants.DEFAULT_CODE_REVIEW_EFFORT
11
+ in review_constants.ALL_EFFORT_TOKENS_IN_ASCENDING_ORDER
12
+ )
@@ -0,0 +1,60 @@
1
+ """The contrast-framing form list stays usable by both linters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dev_env_scripts_constants.contrast_framing_constants import (
6
+ ALL_CONTRAST_FRAMING_FORMS,
7
+ CONTRAST_FRAMING_MESSAGE_TEMPLATE,
8
+ )
9
+
10
+
11
+ def test_every_form_carries_a_distinct_name() -> None:
12
+ all_names = [each_form.name for each_form in ALL_CONTRAST_FRAMING_FORMS]
13
+
14
+ assert len(set(all_names)) == len(all_names)
15
+
16
+
17
+ def test_no_form_matches_prose_that_states_what_is() -> None:
18
+ all_clean_lines = (
19
+ "The function runs more than 30 lines.",
20
+ "The reader reads more than 30 lines a minute.",
21
+ "The check names the failing line and the fix.",
22
+ "Push the branch and read the verdict.",
23
+ "Three attempts, then park the member.",
24
+ )
25
+ for each_form in ALL_CONTRAST_FRAMING_FORMS:
26
+ for each_line in all_clean_lines:
27
+ assert each_form.pattern.search(each_line) is None
28
+
29
+
30
+ def test_every_pattern_reports_the_words_that_carry_the_contrast() -> None:
31
+ all_expected_matches = {
32
+ "trailing-comma-not": (", not s", "a diff regression, not shared code"),
33
+ "corrective-it-is-not": (
34
+ "it is not a flake, it is",
35
+ "it is not a flake, it is a bug",
36
+ ),
37
+ "substitution-rather-than": ("rather than", "park it rather than fix it"),
38
+ "additive-not-just": ("not just", "not just for today"),
39
+ "comparative-ranking": (
40
+ "matters more than",
41
+ "precision matters more than coverage",
42
+ ),
43
+ "substitution-as-opposed-to": (
44
+ "as opposed to",
45
+ "a warning as opposed to a failure",
46
+ ),
47
+ }
48
+ for each_form in ALL_CONTRAST_FRAMING_FORMS:
49
+ expected_text, example_line = all_expected_matches[each_form.name]
50
+ assert each_form.pattern.search(example_line).group(0) == expected_text
51
+
52
+
53
+ def test_the_message_template_carries_the_three_parts() -> None:
54
+ message = CONTRAST_FRAMING_MESSAGE_TEMPLATE.format(
55
+ name="trailing-comma-not", text="a fix, not a workaround", guidance="State it."
56
+ )
57
+
58
+ assert "trailing-comma-not" in message
59
+ assert "a fix, not a workaround" in message
60
+ assert message.endswith("State it.")
@@ -8,6 +8,10 @@ from collections.abc import Sequence
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
 
11
+ from contrast_framing import describe_contrast_framing, find_contrast_framing
12
+ from dev_env_scripts_constants.contrast_framing_constants import (
13
+ CONTRAST_FRAMING_FINDING_CODE,
14
+ )
11
15
  from durable_post_lint_config.config.constants import (
12
16
  ACTION_PR_CREATE,
13
17
  ACTION_PR_EDIT,
@@ -134,10 +138,30 @@ def _description_heading_findings(body_text: str) -> list[DurablePostFinding]:
134
138
  ]
135
139
 
136
140
 
141
+ def _contrast_framing_findings(text: str) -> list[DurablePostFinding]:
142
+ all_lines = text.splitlines()
143
+ return [
144
+ DurablePostFinding(
145
+ code=CONTRAST_FRAMING_FINDING_CODE,
146
+ message=describe_contrast_framing(
147
+ all_lines[each_line_number - 1], each_form_name
148
+ ),
149
+ )
150
+ for each_line_number, _each_column, each_form_name in find_contrast_framing(
151
+ text
152
+ )
153
+ ]
154
+
155
+
137
156
  def _title_findings(title: str | None) -> list[DurablePostFinding]:
138
- if title is None or CONVENTIONAL_TITLE_PATTERN.fullmatch(title) is not None:
157
+ if title is None:
139
158
  return []
140
- return [DurablePostFinding(code=INVALID_TITLE_CODE, message=INVALID_TITLE_MESSAGE)]
159
+ all_findings = _contrast_framing_findings(title)
160
+ if CONVENTIONAL_TITLE_PATTERN.fullmatch(title) is None:
161
+ all_findings.append(
162
+ DurablePostFinding(code=INVALID_TITLE_CODE, message=INVALID_TITLE_MESSAGE)
163
+ )
164
+ return all_findings
141
165
 
142
166
 
143
167
  def _is_release_automation_branch(head_branch: str | None) -> bool:
@@ -172,8 +196,10 @@ def _body_findings(
172
196
  )
173
197
  if _is_release_automation_branch(head_branch):
174
198
  all_findings.extend(_release_body_findings(body_text))
175
- elif action in ALL_PR_DESCRIPTION_ACTIONS:
176
- all_findings.extend(_description_heading_findings(body_text))
199
+ else:
200
+ if action in ALL_PR_DESCRIPTION_ACTIONS:
201
+ all_findings.extend(_description_heading_findings(body_text))
202
+ all_findings.extend(_contrast_framing_findings(body_text))
177
203
  if find_volatile_path_marker(body_text) is not None:
178
204
  all_findings.append(
179
205
  DurablePostFinding(
@@ -281,21 +281,17 @@ def _run_count(repository_root: Path, stdout: TextIO) -> int:
281
281
  and BACKLOG_EXCEEDED_EXIT_CODE once it passes it.
282
282
  """
283
283
  finding_count = len(all_recorded_findings(repository_root))
284
- if finding_count <= FOLLOWUP_BACKLOG_THRESHOLD:
285
- stdout.write(
286
- BACKLOG_WITHIN_TEMPLATE.format(
287
- finding_count=finding_count, threshold=FOLLOWUP_BACKLOG_THRESHOLD
288
- )
289
- + LINE_SEPARATOR
290
- )
291
- return SUCCESS_EXIT_CODE
284
+ within_threshold = finding_count <= FOLLOWUP_BACKLOG_THRESHOLD
285
+ finding_template = (
286
+ BACKLOG_WITHIN_TEMPLATE if within_threshold else BACKLOG_EXCEEDED_TEMPLATE
287
+ )
292
288
  stdout.write(
293
- BACKLOG_EXCEEDED_TEMPLATE.format(
289
+ finding_template.format(
294
290
  finding_count=finding_count, threshold=FOLLOWUP_BACKLOG_THRESHOLD
295
291
  )
296
292
  + LINE_SEPARATOR
297
293
  )
298
- return BACKLOG_EXCEEDED_EXIT_CODE
294
+ return SUCCESS_EXIT_CODE if within_threshold else BACKLOG_EXCEEDED_EXIT_CODE
299
295
 
300
296
 
301
297
  def _run_clear(repository_root: Path) -> int:
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """Hold a merge policy document to the branch ruleset it describes.
3
+
4
+ A policy document names the checks a pull request passes before it merges.
5
+ Those names go stale the moment the branch ruleset stops requiring one of
6
+ them, and the document then promises a gate that never runs.
7
+
8
+ The document carries one fenced block whose info string is
9
+ ``required-status-checks``. Each line inside it is one check context, spelled
10
+ the way the ruleset spells it.
11
+
12
+ ::
13
+
14
+ ```required-status-checks
15
+ instruction-pairs / instruction-pairs
16
+ ```
17
+ ok: the ruleset requires that context -> exit 0
18
+ flag: the ruleset dropped that context -> exit 1, one line naming it
19
+
20
+ The ruleset arrives two ways. A checked-in snapshot keeps the comparison
21
+ offline, which is what the paired test suite reads. ``--live`` reads the
22
+ ruleset from the GitHub API, and ``--refresh`` writes the live answer back to
23
+ the snapshot file.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+ import urllib.request
33
+ from collections.abc import Iterator, Sequence
34
+ from pathlib import Path
35
+ from typing import TextIO
36
+
37
+ from dev_env_scripts_constants.merge_gate_check_constants import (
38
+ ALL_GITHUB_TOKEN_VARIABLES,
39
+ FENCE_MARKER,
40
+ JSON_INDENT_WIDTH,
41
+ MERGE_GATE_CLEAN_EXIT_CODE,
42
+ MERGE_GATE_MISSING_CHECK_EXIT_CODE,
43
+ REQUIRED_CHECK_BLOCK_INFO_STRING,
44
+ REQUIRED_STATUS_CHECKS_RULE_TYPE,
45
+ RULE_CONTEXT_KEY,
46
+ RULE_PARAMETERS_KEY,
47
+ RULE_TYPE_KEY,
48
+ RULESET_API_ACCEPT_HEADER,
49
+ RULESET_API_TEMPLATE,
50
+ RULESET_REQUEST_TIMEOUT_SECONDS,
51
+ UTF8_ENCODING,
52
+ )
53
+
54
+
55
+ def parse_declared_contexts(document_text: str) -> list[str]:
56
+ """Read the check contexts a policy document declares, in document order.
57
+
58
+ ::
59
+
60
+ parse_declared_contexts("```required-status-checks\\nRuff\\n```\\n")
61
+ ok: ["Ruff"]
62
+
63
+ A document with no such block declares nothing.
64
+
65
+ Args:
66
+ document_text: The whole policy document.
67
+
68
+ Returns:
69
+ The contexts inside the ``required-status-checks`` block, in the order
70
+ the document lists them, and an empty list when the block is absent.
71
+ """
72
+ all_stripped_lines = [each_line.strip() for each_line in document_text.splitlines()]
73
+ opening_fence = FENCE_MARKER + REQUIRED_CHECK_BLOCK_INFO_STRING
74
+ if opening_fence not in all_stripped_lines:
75
+ return []
76
+ block_body_index = all_stripped_lines.index(opening_fence) + 1
77
+ all_declared_contexts: list[str] = []
78
+ for each_line in all_stripped_lines[block_body_index:]:
79
+ if each_line == FENCE_MARKER:
80
+ break
81
+ if each_line:
82
+ all_declared_contexts.append(each_line)
83
+ return all_declared_contexts
84
+
85
+
86
+ def ruleset_contexts(ruleset_payload: object) -> set[str]:
87
+ """Read every status-check context a branch ruleset requires.
88
+
89
+ A rule of another type carries no context and is passed over.
90
+
91
+ Args:
92
+ ruleset_payload: The list of rules the branch-rules endpoint returns.
93
+
94
+ Returns:
95
+ Every context named by a ``required_status_checks`` rule.
96
+ """
97
+ all_required_contexts: set[str] = set()
98
+ for each_rule in _required_status_check_rules(ruleset_payload):
99
+ all_required_contexts.update(_rule_contexts(each_rule))
100
+ return all_required_contexts
101
+
102
+
103
+ def missing_contexts(document_text: str, ruleset_payload: object) -> list[str]:
104
+ """Name the declared contexts the ruleset stopped requiring.
105
+
106
+ An empty list means the ruleset requires every check the document names,
107
+ so the document's promise holds.
108
+
109
+ Args:
110
+ document_text: The whole policy document.
111
+ ruleset_payload: The list of rules the branch-rules endpoint returns.
112
+
113
+ Returns:
114
+ The declared contexts the ruleset leaves out, in document order.
115
+ """
116
+ all_required_contexts = ruleset_contexts(ruleset_payload)
117
+ return [
118
+ each_context
119
+ for each_context in parse_declared_contexts(document_text)
120
+ if each_context not in all_required_contexts
121
+ ]
122
+
123
+
124
+ def main(all_arguments: Sequence[str]) -> int:
125
+ """Compare the document against the snapshot, or against the live ruleset.
126
+
127
+ Args:
128
+ all_arguments: The command-line arguments after the program name.
129
+
130
+ Returns:
131
+ ``0`` when the ruleset requires every declared context, and ``1`` with
132
+ one line per context the ruleset leaves out.
133
+ """
134
+ parser = _build_parser()
135
+ parsed_arguments = parser.parse_args(all_arguments)
136
+ wants_live_ruleset = parsed_arguments.live or parsed_arguments.refresh
137
+ if wants_live_ruleset and not parsed_arguments.repository:
138
+ parser.error("--live and --refresh need --repository")
139
+ ruleset_payload = _resolve_ruleset(parsed_arguments)
140
+ document_text = parsed_arguments.document.read_text(encoding=UTF8_ENCODING)
141
+ return _report_missing(
142
+ parsed_arguments.document,
143
+ missing_contexts(document_text, ruleset_payload),
144
+ sys.stdout,
145
+ )
146
+
147
+
148
+ def _required_status_check_rules(
149
+ ruleset_payload: object,
150
+ ) -> Iterator[dict[str, object]]:
151
+ if not isinstance(ruleset_payload, list):
152
+ return
153
+ for each_rule in ruleset_payload:
154
+ if isinstance(each_rule, dict) and (
155
+ each_rule.get(RULE_TYPE_KEY) == REQUIRED_STATUS_CHECKS_RULE_TYPE
156
+ ):
157
+ yield each_rule
158
+
159
+
160
+ def _rule_contexts(all_rule_fields: dict[str, object]) -> set[str]:
161
+ parameters = all_rule_fields.get(RULE_PARAMETERS_KEY)
162
+ if not isinstance(parameters, dict):
163
+ return set()
164
+ all_declared_checks = parameters.get(REQUIRED_STATUS_CHECKS_RULE_TYPE)
165
+ if not isinstance(all_declared_checks, list):
166
+ return set()
167
+ return {
168
+ each_check[RULE_CONTEXT_KEY]
169
+ for each_check in all_declared_checks
170
+ if isinstance(each_check, dict)
171
+ and isinstance(each_check.get(RULE_CONTEXT_KEY), str)
172
+ }
173
+
174
+
175
+ def _read_github_token() -> str | None:
176
+ for each_variable_name in ALL_GITHUB_TOKEN_VARIABLES:
177
+ token = os.environ.get(each_variable_name)
178
+ if token:
179
+ return token
180
+ return None
181
+
182
+
183
+ def _fetch_ruleset(repository: str, branch: str) -> object:
184
+ request = urllib.request.Request(
185
+ RULESET_API_TEMPLATE.format(repository=repository, branch=branch),
186
+ headers={"Accept": RULESET_API_ACCEPT_HEADER},
187
+ )
188
+ token = _read_github_token()
189
+ if token:
190
+ request.add_header("Authorization", f"Bearer {token}")
191
+ with urllib.request.urlopen(
192
+ request, timeout=RULESET_REQUEST_TIMEOUT_SECONDS
193
+ ) as api_reply:
194
+ return json.loads(api_reply.read().decode(UTF8_ENCODING))
195
+
196
+
197
+ def _resolve_ruleset(parsed_arguments: argparse.Namespace) -> object:
198
+ if not (parsed_arguments.live or parsed_arguments.refresh):
199
+ return json.loads(parsed_arguments.ruleset.read_text(encoding=UTF8_ENCODING))
200
+ ruleset_payload = _fetch_ruleset(
201
+ parsed_arguments.repository, parsed_arguments.branch
202
+ )
203
+ if parsed_arguments.refresh:
204
+ parsed_arguments.ruleset.write_text(
205
+ json.dumps(ruleset_payload, indent=JSON_INDENT_WIDTH) + "\n",
206
+ encoding=UTF8_ENCODING,
207
+ )
208
+ return ruleset_payload
209
+
210
+
211
+ def _report_missing(
212
+ document_path: Path, all_absent_contexts: Sequence[str], stream: TextIO
213
+ ) -> int:
214
+ if not all_absent_contexts:
215
+ stream.write(f"{document_path}: the ruleset requires every named check\n")
216
+ return MERGE_GATE_CLEAN_EXIT_CODE
217
+ stream.writelines(
218
+ f"{document_path}: the branch ruleset does not require {each_context!r}\n"
219
+ for each_context in all_absent_contexts
220
+ )
221
+ return MERGE_GATE_MISSING_CHECK_EXIT_CODE
222
+
223
+
224
+ def _build_parser() -> argparse.ArgumentParser:
225
+ parser = argparse.ArgumentParser(description="Check a merge policy document.")
226
+ parser.add_argument("--document", required=True, type=Path)
227
+ parser.add_argument("--ruleset", required=True, type=Path)
228
+ parser.add_argument("--repository")
229
+ parser.add_argument("--branch", default="main")
230
+ parser.add_argument("--live", action="store_true")
231
+ parser.add_argument("--refresh", action="store_true")
232
+ return parser
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main(sys.argv[1:]))