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.
- package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/.agents/skills/team-advisor/SKILL.md +2 -2
- package/_shared/advisor/reference/identity.md +1 -1
- package/_shared/advisor/reference/spawn-walk-log.md +1 -1
- package/_shared/advisor/reference/warm-up.md +3 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +50 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/claude_account_choice.py +246 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_claude_account_choice.py +217 -0
- package/scripts/test_claude_account_profile.py +220 -0
- package/scripts/test_claude_chain_usage.py +40 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- 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
|
|
157
|
+
if title is None:
|
|
139
158
|
return []
|
|
140
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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(
|
package/scripts/followup_cli.py
CHANGED
|
@@ -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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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:]))
|