claude-dev-env 7.0.0 → 7.2.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import asdict
11
+ from pathlib import Path
12
+ from typing import TextIO
13
+
14
+ scripts_directory = Path(__file__).resolve().parents[1]
15
+ if str(scripts_directory) not in sys.path:
16
+ sys.path.insert(0, str(scripts_directory))
17
+
18
+ from pr_verification.config.constants import INCOMPLETE_EXIT_CODE, SUCCESS_EXIT_CODE
19
+ from pr_verification.github import GitHubAppAuthenticator
20
+ from pr_verification.lock import SupervisorLock, SupervisorLockError
21
+ from pr_verification.model import RepositorySettings
22
+
23
+ from automatic_advisory.config.constants import (
24
+ POLL_ERROR_KEY,
25
+ POLL_ERROR_LOG_LINE_LIMIT,
26
+ REPORT_NEWLINE,
27
+ STATE_FILE_SUFFIX,
28
+ STATE_STATUS_KEY,
29
+ STATE_STATUS_NEVER_RUN,
30
+ UTF8_ENCODING,
31
+ )
32
+ from automatic_advisory.configuration import (
33
+ AdvisoryConfigurationError,
34
+ load_advisory_settings,
35
+ )
36
+ from automatic_advisory.model import AdvisorySettings, AdvisoryState
37
+ from automatic_advisory.runner import (
38
+ AdvisoryGitHub,
39
+ AutomaticAdvisoryRunner,
40
+ )
41
+ from automatic_advisory.window_flags import detached_poller_creation_flags
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ """Build the automatic advisory command parser.
46
+
47
+ Returns:
48
+ Parser for status, one-shot, and polling modes.
49
+ """
50
+ parser = argparse.ArgumentParser(prog="cde automatic-advisory")
51
+ parser.add_argument("--settings", type=Path, required=True)
52
+ mode = parser.add_mutually_exclusive_group(required=True)
53
+ mode.add_argument("--once", action="store_true")
54
+ mode.add_argument("--poll", action="store_true")
55
+ mode.add_argument("--status", action="store_true")
56
+ mode.add_argument("--start", action="store_true")
57
+ parser.add_argument("--rerun", action="store_true")
58
+ return parser
59
+
60
+
61
+ def main(
62
+ all_arguments: Sequence[str],
63
+ *,
64
+ stdout: TextIO = sys.stdout,
65
+ stderr: TextIO = sys.stderr,
66
+ sleeper: Callable[[float], None] = time.sleep,
67
+ ) -> int:
68
+ """Run the selected automatic advisory command.
69
+
70
+ Args:
71
+ all_arguments: Command arguments after the executable name.
72
+ stdout: Stream for state output.
73
+ stderr: Stream for configuration and runtime errors.
74
+ sleeper: Delay function used by polling mode.
75
+
76
+ Returns:
77
+ Zero for a completed command or three for an incomplete run.
78
+ """
79
+ return _run_main_command(all_arguments, stdout, stderr, sleeper)
80
+
81
+
82
+ def _run_main_command(
83
+ all_arguments: Sequence[str],
84
+ stdout: TextIO,
85
+ stderr: TextIO,
86
+ sleeper: Callable[[float], None],
87
+ ) -> int:
88
+ try:
89
+ parsed_arguments, settings = _load_command_inputs(all_arguments)
90
+ return _run_selected_mode(parsed_arguments, settings, stdout, sleeper)
91
+ except (AdvisoryConfigurationError, OSError, SupervisorLockError) as error:
92
+ stderr.write(f"{error}{REPORT_NEWLINE}")
93
+ return INCOMPLETE_EXIT_CODE
94
+
95
+
96
+ def _load_command_inputs(
97
+ all_arguments: Sequence[str],
98
+ ) -> tuple[argparse.Namespace, AdvisorySettings]:
99
+ parsed_arguments = build_parser().parse_args(list(all_arguments))
100
+ return parsed_arguments, load_advisory_settings(parsed_arguments.settings)
101
+
102
+
103
+ def _run_selected_mode(
104
+ parsed_arguments: argparse.Namespace,
105
+ settings: AdvisorySettings,
106
+ stdout: TextIO,
107
+ sleeper: Callable[[float], None],
108
+ ) -> int:
109
+ if parsed_arguments.status:
110
+ write_status(settings, stdout)
111
+ return SUCCESS_EXIT_CODE
112
+ if parsed_arguments.start:
113
+ return start_polling(parsed_arguments.settings)
114
+ advisory_runner = _build_runner(settings)
115
+ if parsed_arguments.once:
116
+ write_states(advisory_runner.run_once(parsed_arguments.rerun), stdout)
117
+ return SUCCESS_EXIT_CODE
118
+ with SupervisorLock(settings.poll_lock_root):
119
+ return run_polling(
120
+ advisory_runner,
121
+ settings.poll_seconds,
122
+ stdout,
123
+ settings.poll_error_log_path,
124
+ sleeper,
125
+ )
126
+
127
+
128
+ def start_polling(settings_path: Path) -> int:
129
+ """Start one detached polling process and return without waiting.
130
+
131
+ Args:
132
+ settings_path: JSON settings passed to the detached process.
133
+
134
+ Returns:
135
+ Zero after the child process starts.
136
+ """
137
+ process_flags = detached_poller_creation_flags()
138
+ resolved_settings_path = settings_path.resolve()
139
+ subprocess.Popen(
140
+ (
141
+ sys.executable,
142
+ str(Path(__file__).resolve()),
143
+ "--settings",
144
+ str(resolved_settings_path),
145
+ "--poll",
146
+ ),
147
+ cwd=Path(__file__).resolve().parents[1],
148
+ stdin=subprocess.DEVNULL,
149
+ stdout=subprocess.DEVNULL,
150
+ stderr=subprocess.DEVNULL,
151
+ creationflags=process_flags,
152
+ start_new_session=True,
153
+ )
154
+ return SUCCESS_EXIT_CODE
155
+
156
+
157
+ def _build_runner(
158
+ settings: AdvisorySettings,
159
+ ) -> AutomaticAdvisoryRunner:
160
+ authenticator = GitHubAppAuthenticator(
161
+ settings.api_url,
162
+ settings.app_id,
163
+ settings.installation_id,
164
+ settings.private_key_path,
165
+ )
166
+ return AutomaticAdvisoryRunner(
167
+ settings,
168
+ None,
169
+ github_factory=lambda repository: _issue_repository_api(
170
+ authenticator, repository
171
+ ),
172
+ )
173
+
174
+
175
+ def _issue_repository_api(
176
+ authenticator: GitHubAppAuthenticator,
177
+ repository: RepositorySettings,
178
+ ) -> AdvisoryGitHub:
179
+ return authenticator.issue_repository_api(
180
+ repository,
181
+ should_write_issue_labels=True,
182
+ )
183
+
184
+
185
+ def run_polling(
186
+ advisory_runner: AutomaticAdvisoryRunner,
187
+ poll_seconds: float,
188
+ stdout: TextIO,
189
+ poll_error_log_path: Path,
190
+ sleeper: Callable[[float], None],
191
+ ) -> int:
192
+ """Run advisory checks until the caller interrupts polling.
193
+
194
+ A SupervisorLockError or OSError in a cycle writes one poll_error JSON
195
+ line to stdout and to poll-errors.log, newest 200 lines, then the next
196
+ cycle runs. A detached poller sends stdout to DEVNULL, so the log file
197
+ beside the state files is the readable copy.
198
+
199
+ Args:
200
+ advisory_runner: Runner for explicit checkout and pull request pairs.
201
+ poll_seconds: Delay between completed cycles.
202
+ stdout: Stream for each persisted state and each failed cycle.
203
+ poll_error_log_path: Log file receiving each failed cycle line.
204
+ sleeper: Delay function used between cycles.
205
+
206
+ Returns:
207
+ Zero after a user interrupt.
208
+ """
209
+ try:
210
+ _repeat_poll_cycles(
211
+ advisory_runner, poll_seconds, stdout, poll_error_log_path, sleeper
212
+ )
213
+ except KeyboardInterrupt:
214
+ return SUCCESS_EXIT_CODE
215
+
216
+
217
+ def _repeat_poll_cycles(
218
+ advisory_runner: AutomaticAdvisoryRunner,
219
+ poll_seconds: float,
220
+ stdout: TextIO,
221
+ poll_error_log_path: Path,
222
+ sleeper: Callable[[float], None],
223
+ ) -> None:
224
+ while True:
225
+ _run_one_poll_cycle(advisory_runner, stdout, poll_error_log_path)
226
+ sleeper(poll_seconds)
227
+
228
+
229
+ def _run_one_poll_cycle(
230
+ advisory_runner: AutomaticAdvisoryRunner,
231
+ stdout: TextIO,
232
+ poll_error_log_path: Path,
233
+ ) -> None:
234
+ try:
235
+ write_states(advisory_runner.run_once(), stdout)
236
+ except (OSError, SupervisorLockError) as cycle_error:
237
+ _write_poll_error(cycle_error, stdout, poll_error_log_path)
238
+
239
+
240
+ def _write_poll_error(
241
+ cycle_error: Exception,
242
+ stdout: TextIO,
243
+ poll_error_log_path: Path,
244
+ ) -> None:
245
+ error_line = (
246
+ json.dumps({POLL_ERROR_KEY: str(cycle_error)}, sort_keys=True) + REPORT_NEWLINE
247
+ )
248
+ stdout.write(error_line)
249
+ stdout.flush()
250
+ _append_poll_error_line(poll_error_log_path, error_line)
251
+
252
+
253
+ def _append_poll_error_line(poll_error_log_path: Path, error_line: str) -> None:
254
+ """Append one failed-cycle JSON line, keeping the newest 200 lines.
255
+
256
+ Skip the log on OSError or ValueError. A raise here would stop the poller.
257
+ The line already reached stdout. Undecodable bytes are replaced on read.
258
+
259
+ Args:
260
+ poll_error_log_path: Log file receiving the line.
261
+ error_line: JSON error line, newline included.
262
+ """
263
+ try:
264
+ _replace_poll_error_log(poll_error_log_path, error_line)
265
+ except (OSError, ValueError):
266
+ return
267
+
268
+
269
+ def _replace_poll_error_log(poll_error_log_path: Path, error_line: str) -> None:
270
+ poll_error_log_path.parent.mkdir(parents=True, exist_ok=True)
271
+ all_lines = _poll_error_log_lines(poll_error_log_path)
272
+ all_lines.append(error_line)
273
+ temporary_log_path = poll_error_log_path.with_name(
274
+ poll_error_log_path.name + STATE_FILE_SUFFIX
275
+ )
276
+ temporary_log_path.write_text(
277
+ "".join(all_lines[-POLL_ERROR_LOG_LINE_LIMIT:]), encoding=UTF8_ENCODING
278
+ )
279
+ os.replace(temporary_log_path, poll_error_log_path)
280
+
281
+
282
+ def _poll_error_log_lines(poll_error_log_path: Path) -> list[str]:
283
+ if not poll_error_log_path.exists():
284
+ return []
285
+ return poll_error_log_path.read_text(
286
+ encoding=UTF8_ENCODING, errors="replace"
287
+ ).splitlines(keepends=True)
288
+
289
+
290
+ def write_states(
291
+ all_states: tuple[AdvisoryState, ...],
292
+ stdout: TextIO,
293
+ ) -> None:
294
+ """Write one JSON state line for each registration.
295
+
296
+ Args:
297
+ all_states: Persisted registration states.
298
+ stdout: Stream receiving each JSON state line.
299
+ """
300
+ stdout.writelines(
301
+ json.dumps(asdict(each_state), sort_keys=True) + REPORT_NEWLINE
302
+ for each_state in all_states
303
+ )
304
+ stdout.flush()
305
+
306
+
307
+ def write_status(settings: AdvisorySettings, stdout: TextIO) -> None:
308
+ """Write the latest state or never-run marker for each registration.
309
+
310
+ Args:
311
+ settings: Explicit advisory registrations to inspect.
312
+ stdout: Stream receiving one JSON record per registration.
313
+ """
314
+ for each_registration in settings.registrations:
315
+ state_path = each_registration.state_path
316
+ if not state_path.is_file():
317
+ stdout.write(
318
+ json.dumps(
319
+ {
320
+ "repository": each_registration.repository.slug,
321
+ "pull_request": each_registration.pull_request_number,
322
+ STATE_STATUS_KEY: STATE_STATUS_NEVER_RUN,
323
+ },
324
+ sort_keys=True,
325
+ )
326
+ + REPORT_NEWLINE
327
+ )
328
+ continue
329
+ stdout.write(state_path.read_text(encoding="utf-8"))
330
+ stdout.flush()
331
+
332
+
333
+ if __name__ == "__main__":
334
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1 @@
1
+ """Configuration for the automatic advisory runner."""
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ SETTINGS_VERSION = 1
4
+ SETTINGS_VERSION_KEY = "version"
5
+ SETTINGS_REGISTRATIONS_KEY = "registrations"
6
+ SETTINGS_API_URL_KEY = "api_url"
7
+ SETTINGS_APP_ID_KEY = "app_id"
8
+ SETTINGS_INSTALLATION_ID_KEY = "installation_id"
9
+ SETTINGS_SIGNING_FILE_FIELD = "private_" + "key_path"
10
+ SETTINGS_POLL_SECONDS_KEY = "poll_seconds"
11
+ SETTINGS_CHILD_TIMEOUT_SECONDS_KEY = "child_timeout_seconds"
12
+ REGISTRATION_REPOSITORY_KEY = "repository"
13
+ REGISTRATION_PULL_REQUEST_KEY = "pull_request"
14
+ REGISTRATION_CHECKOUT_KEY = "checkout"
15
+ REGISTRATION_MANIFEST_KEY = "manifest"
16
+ REGISTRATION_REPORT_KEY = "report"
17
+ REGISTRATION_STATE_KEY = "state"
18
+ REGISTRATION_BASE_REF_KEY = "base_ref"
19
+ REGISTRATION_REMOTE_KEY = "remote"
20
+ STATE_VERSION = 1
21
+ STATE_VERSION_KEY = "version"
22
+ STATE_REPOSITORY_KEY = "repository"
23
+ STATE_PULL_REQUEST_KEY = "pull_request"
24
+ STATE_STATUS_KEY = "status"
25
+ STATE_REASON_KEY = "reason"
26
+ STATE_HEAD_SHA_KEY = "head_sha"
27
+ STATE_BASE_SHA_KEY = "base_sha"
28
+ STATE_LAST_POLL_KEY = "last_poll"
29
+ STATE_LAST_RUN_KEY = "last_run"
30
+ STATE_REPORT_KEY = "report"
31
+ STATE_STATUS_WAITING = "waiting"
32
+ STATE_STATUS_OFFLINE = "offline"
33
+ STATE_STATUS_CLOSED = "closed"
34
+ STATE_STATUS_PASSED = "passed"
35
+ STATE_STATUS_FAILED = "failed"
36
+ STATE_STATUS_PENDING = "pending"
37
+ STATE_STATUS_ERROR = "error"
38
+ STATE_STATUS_NEVER_RUN = "never_run"
39
+ POLL_ERROR_KEY = "poll_error"
40
+ POLL_ERROR_LOG_FILE_NAME = "poll-errors.log"
41
+ POLL_ERROR_LOG_LINE_LIMIT = 200
42
+ ALL_TERMINAL_STATUSES = frozenset({STATE_STATUS_PASSED, STATE_STATUS_FAILED})
43
+ STATE_REASON_CHECKOUT_MISMATCH = "checkout does not match the open pull request"
44
+ STATE_REASON_DIRTY_CHECKOUT = "checkout has uncommitted changes"
45
+ STATE_REASON_PULL_REQUEST_CLOSED = "pull request is not open"
46
+ STATE_REASON_GITHUB_UNAVAILABLE = "GitHub is unavailable"
47
+ STATE_REASON_BASE_FETCH_FAILED = "base ref fetch failed"
48
+ STATE_REASON_PUBLISH_FAILED = "report publication failed"
49
+ STATE_REASON_CHILD_INCOMPLETE = "verification child did not produce a report"
50
+ STATE_REASON_UNCHANGED = "unchanged remote head and base"
51
+ GIT_EXECUTABLE = "git"
52
+ GIT_FETCH_ARGUMENT = "fetch"
53
+ GIT_NO_TAGS_FLAG = "--no-tags"
54
+ GIT_TERMINAL_PROMPT_ENVIRONMENT_KEY = "GIT_TERMINAL_PROMPT"
55
+ GIT_PROMPT_DISABLED_VALUE = "0"
56
+ GIT_FORCE_FLAG = "--force"
57
+ ALL_GIT_HEAD_ARGUMENTS = ("rev-parse", "HEAD")
58
+ ALL_GIT_STATUS_ARGUMENTS = ("status", "--porcelain=v1", "--untracked-files=all")
59
+ GIT_BASE_REFSPEC_TEMPLATE = (
60
+ "+refs/heads/{base_ref}:refs/cde/advisory-base/{pull_request}"
61
+ )
62
+ GIT_REMOTE_BASE_REFSPEC_TEMPLATE = (
63
+ "+refs/heads/{base_ref}:refs/remotes/{remote_name}/{base_ref}"
64
+ )
65
+ GIT_BASE_REF_TEMPLATE = "refs/cde/advisory-base/{pull_request}"
66
+ LOCAL_VERIFICATION_CLI_FILENAME = "cli.py"
67
+ WINDOWS_PRIORITY_CLASS_ATTRIBUTE = "BELOW_NORMAL_PRIORITY_CLASS"
68
+ WINDOWS_PLATFORM = "win32"
69
+ PYTHON_MODULE_ARGUMENT = "-m"
70
+ WINDOWS_PROCESS_HOST_MODULE = "automatic_advisory.process_host"
71
+ WINDOWS_PROCESS_START_SIGNAL = "1"
72
+ WINDOWS_PROCESS_SET_QUOTA_ACCESS = 0x0100
73
+ WINDOWS_PROCESS_TERMINATE_ACCESS = 0x0001
74
+ WINDOWS_EXTENDED_LIMIT_INFORMATION_CLASS = 9
75
+ WINDOWS_JOB_KILL_ON_CLOSE_LIMIT = 0x2000
76
+ DEFAULT_CLONE_URL_SUFFIX = ".git"
77
+ POLL_LOCK_DIRECTORY_NAME = ".poll-process"
78
+ CREATE_NEW_PROCESS_GROUP_ATTRIBUTE = "CREATE_NEW_PROCESS_GROUP"
79
+ CREATE_NO_WINDOW_ATTRIBUTE = "CREATE_NO_WINDOW"
80
+ CHILD_MANIFEST_FLAG = "--manifest"
81
+ CHILD_REPOSITORY_FLAG = "--repo"
82
+ CHILD_BASE_FLAG = "--base"
83
+ CHILD_EXECUTOR_FLAG = "--executor"
84
+ CHILD_OUTPUT_FLAG = "--output"
85
+ SCOPED_VERIFICATION_SCRIPT_PATH = ".github/ci/local_verify.py"
86
+ PYTHONPATH_ENVIRONMENT_KEY = "PYTHONPATH"
87
+ PYTEST_AUTO_WORKERS_ENVIRONMENT_KEY = "PYTEST_XDIST_AUTO_NUM_WORKERS"
88
+ PYTEST_AUTO_WORKERS_LIMIT = "2"
89
+ STATE_FILE_SUFFIX = ".tmp"
90
+ SELECTED_MANIFEST_SUFFIX = ".selected-manifest.json"
91
+ UTF8_ENCODING = "utf-8"
92
+ REPORT_NEWLINE = "\n"
93
+ JSON_INDENT_SPACES = 2
@@ -0,0 +1,4 @@
1
+ DEFAULT_POLL_SECONDS = 60.0
2
+ DEFAULT_CHILD_TIMEOUT_SECONDS = 1800.0
3
+ GIT_FETCH_TIMEOUT_SECONDS = 30.0
4
+ CHILD_CLEANUP_TIMEOUT_SECONDS = 1.0
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ from collections.abc import Mapping
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from pr_verification.config.constants import GITHUB_API_URL, REPOSITORY_SLUG_PATTERN
9
+ from pr_verification.model import RepositorySettings
10
+
11
+ from .config.constants import (
12
+ DEFAULT_CLONE_URL_SUFFIX,
13
+ REGISTRATION_BASE_REF_KEY,
14
+ REGISTRATION_CHECKOUT_KEY,
15
+ REGISTRATION_MANIFEST_KEY,
16
+ REGISTRATION_PULL_REQUEST_KEY,
17
+ REGISTRATION_REMOTE_KEY,
18
+ REGISTRATION_REPORT_KEY,
19
+ REGISTRATION_REPOSITORY_KEY,
20
+ REGISTRATION_STATE_KEY,
21
+ SETTINGS_API_URL_KEY,
22
+ SETTINGS_APP_ID_KEY,
23
+ SETTINGS_CHILD_TIMEOUT_SECONDS_KEY,
24
+ SETTINGS_INSTALLATION_ID_KEY,
25
+ SETTINGS_POLL_SECONDS_KEY,
26
+ SETTINGS_REGISTRATIONS_KEY,
27
+ SETTINGS_SIGNING_FILE_FIELD,
28
+ SETTINGS_VERSION,
29
+ SETTINGS_VERSION_KEY,
30
+ )
31
+ from .config.timing import DEFAULT_CHILD_TIMEOUT_SECONDS, DEFAULT_POLL_SECONDS
32
+ from .model import AdvisoryRegistration, AdvisorySettings
33
+
34
+
35
+ class AdvisoryConfigurationError(ValueError):
36
+ """Raised when automatic advisory settings are invalid."""
37
+
38
+
39
+ def load_advisory_settings(settings_path: Path) -> AdvisorySettings:
40
+ """Load and validate the explicit automatic advisory registrations.
41
+
42
+ Args:
43
+ settings_path: JSON file containing the advisory settings.
44
+
45
+ Returns:
46
+ Validated settings for the registered checkout and pull request pairs.
47
+
48
+ Raises:
49
+ AdvisoryConfigurationError: If the file or any registration is invalid.
50
+ """
51
+ try:
52
+ parsed_settings = json.loads(settings_path.read_text(encoding="utf-8"))
53
+ except (OSError, json.JSONDecodeError) as error:
54
+ raise AdvisoryConfigurationError(str(error)) from error
55
+ all_settings = _require_mapping(parsed_settings)
56
+ _require_version(all_settings)
57
+ return _build_settings(all_settings)
58
+
59
+
60
+ def _build_settings(all_settings: Mapping[str, object]) -> AdvisorySettings:
61
+ registrations = _load_registrations(all_settings)
62
+ return AdvisorySettings(
63
+ _load_api_url(all_settings),
64
+ _require_positive_integer(all_settings, SETTINGS_APP_ID_KEY),
65
+ _require_positive_integer(all_settings, SETTINGS_INSTALLATION_ID_KEY),
66
+ _require_absolute_path(all_settings, SETTINGS_SIGNING_FILE_FIELD),
67
+ _load_positive_number(
68
+ all_settings,
69
+ SETTINGS_POLL_SECONDS_KEY,
70
+ DEFAULT_POLL_SECONDS,
71
+ ),
72
+ _load_positive_number(
73
+ all_settings,
74
+ SETTINGS_CHILD_TIMEOUT_SECONDS_KEY,
75
+ DEFAULT_CHILD_TIMEOUT_SECONDS,
76
+ ),
77
+ registrations,
78
+ )
79
+
80
+
81
+ def _load_registrations(
82
+ all_settings: Mapping[str, object],
83
+ ) -> tuple[AdvisoryRegistration, ...]:
84
+ raw_registrations = all_settings.get(SETTINGS_REGISTRATIONS_KEY)
85
+ if not isinstance(raw_registrations, list) or not raw_registrations:
86
+ raise AdvisoryConfigurationError("registrations must be a non-empty array")
87
+ all_registrations = tuple(
88
+ _load_registration(each_registration) for each_registration in raw_registrations
89
+ )
90
+ all_repositories = tuple(
91
+ each_registration.repository.slug for each_registration in all_registrations
92
+ )
93
+ if len(set(all_repositories)) != len(all_repositories):
94
+ raise AdvisoryConfigurationError(
95
+ "each repository can have only one automatic advisory registration"
96
+ )
97
+ return all_registrations
98
+
99
+
100
+ def _load_registration(raw_registration: object) -> AdvisoryRegistration:
101
+ registration = _require_mapping(raw_registration)
102
+ repository_slug = _require_text(registration, REGISTRATION_REPOSITORY_KEY)
103
+ if REPOSITORY_SLUG_PATTERN.fullmatch(repository_slug) is None:
104
+ raise AdvisoryConfigurationError("repository must use owner/name")
105
+ pull_request_number = _require_positive_integer(
106
+ registration, REGISTRATION_PULL_REQUEST_KEY
107
+ )
108
+ checkout_path = _require_absolute_path(registration, REGISTRATION_CHECKOUT_KEY)
109
+ manifest_path = _require_relative_manifest(registration)
110
+ report_path = _require_absolute_path(registration, REGISTRATION_REPORT_KEY)
111
+ state_path = _require_absolute_path(registration, REGISTRATION_STATE_KEY)
112
+ return AdvisoryRegistration(
113
+ RepositorySettings(repository_slug, repository_slug + DEFAULT_CLONE_URL_SUFFIX),
114
+ pull_request_number,
115
+ checkout_path,
116
+ manifest_path,
117
+ report_path,
118
+ state_path,
119
+ _require_text(registration, REGISTRATION_BASE_REF_KEY),
120
+ _require_text(registration, REGISTRATION_REMOTE_KEY),
121
+ )
122
+
123
+
124
+ def _load_api_url(all_settings: Mapping[str, object]) -> str:
125
+ return _require_text_or_default(
126
+ all_settings, SETTINGS_API_URL_KEY, GITHUB_API_URL
127
+ ).rstrip("/")
128
+
129
+
130
+ def _load_positive_number(
131
+ all_settings: Mapping[str, object],
132
+ field_name: str,
133
+ default_seconds: float,
134
+ ) -> float:
135
+ raw_number = all_settings.get(field_name, default_seconds)
136
+ if isinstance(raw_number, bool) or not isinstance(raw_number, (float, int)):
137
+ raise AdvisoryConfigurationError(f"{field_name} must be positive")
138
+ if raw_number <= 0 or not math.isfinite(raw_number):
139
+ raise AdvisoryConfigurationError(f"{field_name} must be positive")
140
+ return float(raw_number)
141
+
142
+
143
+ def _require_mapping(raw_payload: object) -> Mapping[str, object]:
144
+ if not isinstance(raw_payload, Mapping):
145
+ raise AdvisoryConfigurationError("settings must be an object")
146
+ return raw_payload
147
+
148
+
149
+ def _require_version(all_settings: Mapping[str, object]) -> None:
150
+ if all_settings.get(SETTINGS_VERSION_KEY) != SETTINGS_VERSION:
151
+ raise AdvisoryConfigurationError("settings version is unsupported")
152
+
153
+
154
+ def _require_text(all_settings: Mapping[str, object], field_name: str) -> str:
155
+ field_text = all_settings.get(field_name)
156
+ if not isinstance(field_text, str) or not field_text.strip():
157
+ raise AdvisoryConfigurationError(f"{field_name} must be a non-empty string")
158
+ return field_text
159
+
160
+
161
+ def _require_text_or_default(
162
+ all_settings: Mapping[str, object],
163
+ field_name: str,
164
+ default_text: str,
165
+ ) -> str:
166
+ field_text = all_settings.get(field_name, default_text)
167
+ if not isinstance(field_text, str) or not field_text.strip():
168
+ raise AdvisoryConfigurationError(f"{field_name} must be a non-empty string")
169
+ return field_text
170
+
171
+
172
+ def _require_positive_integer(
173
+ all_settings: Mapping[str, object], field_name: str
174
+ ) -> int:
175
+ field_number = all_settings.get(field_name)
176
+ if isinstance(field_number, bool) or not isinstance(field_number, int):
177
+ raise AdvisoryConfigurationError(f"{field_name} must be positive")
178
+ if field_number < 1:
179
+ raise AdvisoryConfigurationError(f"{field_name} must be positive")
180
+ return field_number
181
+
182
+
183
+ def _require_absolute_path(all_settings: Mapping[str, object], field_name: str) -> Path:
184
+ configured_path = Path(_require_text(all_settings, field_name))
185
+ if not configured_path.is_absolute():
186
+ raise AdvisoryConfigurationError(f"{field_name} must be absolute")
187
+ return configured_path.resolve()
188
+
189
+
190
+ def _require_relative_manifest(
191
+ all_settings: Mapping[str, object],
192
+ ) -> PurePosixPath:
193
+ manifest_text = _require_text(all_settings, REGISTRATION_MANIFEST_KEY)
194
+ manifest_path = PurePosixPath(manifest_text.replace("\\", "/"))
195
+ if manifest_path.is_absolute() or ".." in manifest_path.parts:
196
+ raise AdvisoryConfigurationError("manifest must stay inside checkout")
197
+ return manifest_path