claude-dev-env 7.0.0 → 7.1.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 +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -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 +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -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,350 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import IO
11
+
12
+ from .config.constants import (
13
+ CHILD_BASE_FLAG,
14
+ CHILD_EXECUTOR_FLAG,
15
+ CHILD_OUTPUT_FLAG,
16
+ LOCAL_VERIFICATION_CLI_FILENAME,
17
+ PYTEST_AUTO_WORKERS_ENVIRONMENT_KEY,
18
+ PYTEST_AUTO_WORKERS_LIMIT,
19
+ PYTHON_MODULE_ARGUMENT,
20
+ PYTHONPATH_ENVIRONMENT_KEY,
21
+ SCOPED_VERIFICATION_SCRIPT_PATH,
22
+ WINDOWS_PLATFORM,
23
+ WINDOWS_PRIORITY_CLASS_ATTRIBUTE,
24
+ WINDOWS_PROCESS_HOST_MODULE,
25
+ WINDOWS_PROCESS_START_SIGNAL,
26
+ )
27
+ from .config.timing import CHILD_CLEANUP_TIMEOUT_SECONDS
28
+ from .model import AdvisoryRegistration, ChildOutcome
29
+ from .window_flags import hidden_window_creation_flags
30
+ from .windows_job import (
31
+ _assign_process,
32
+ _close_handle,
33
+ _create_kill_on_close_job,
34
+ _terminate_job,
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class OwnedChildProcess:
40
+ process: subprocess.Popen[str]
41
+ process_group_identifier: int | None
42
+ windows_job_handle: int | None
43
+
44
+
45
+ def run_verification_child(
46
+ registration: AdvisoryRegistration,
47
+ base_ref: str,
48
+ timeout_seconds: float,
49
+ ) -> ChildOutcome:
50
+ """Run the existing local verification CLI with a bounded timeout.
51
+
52
+ Args:
53
+ registration: Checkout, manifest, and report paths for the pair.
54
+ base_ref: Dedicated fetched base revision.
55
+ timeout_seconds: Maximum child runtime.
56
+
57
+ Returns:
58
+ Captured child status and streams.
59
+ """
60
+ scripts_directory = Path(__file__).resolve().parents[1]
61
+ all_arguments = _build_child_arguments(registration, base_ref, scripts_directory)
62
+ child_environment = _build_child_environment(registration, scripts_directory)
63
+ if not _clear_child_artifacts(registration):
64
+ return ChildOutcome(None, "", "child artifacts could not be cleared", False)
65
+ maybe_owned_child = _start_child_process(
66
+ all_arguments, registration, child_environment
67
+ )
68
+ if maybe_owned_child is None:
69
+ return ChildOutcome(None, "", "child process could not start", False)
70
+ return _collect_child_outcome(maybe_owned_child, timeout_seconds)
71
+
72
+
73
+ def _build_child_arguments(
74
+ registration: AdvisoryRegistration,
75
+ base_ref: str,
76
+ scripts_directory: Path,
77
+ ) -> tuple[str, ...]:
78
+ verification_cli_path = (
79
+ scripts_directory / "local_verification" / LOCAL_VERIFICATION_CLI_FILENAME
80
+ )
81
+ return (
82
+ sys.executable,
83
+ str(registration.checkout_path / SCOPED_VERIFICATION_SCRIPT_PATH),
84
+ CHILD_BASE_FLAG,
85
+ base_ref,
86
+ CHILD_EXECUTOR_FLAG,
87
+ str(verification_cli_path),
88
+ CHILD_OUTPUT_FLAG,
89
+ str(registration.report_path),
90
+ )
91
+
92
+
93
+ def _build_child_environment(
94
+ registration: AdvisoryRegistration, scripts_directory: Path
95
+ ) -> dict[str, str]:
96
+ child_environment = os.environ.copy()
97
+ all_python_paths = [str(registration.checkout_path), str(scripts_directory)]
98
+ existing_python_path = child_environment.get(PYTHONPATH_ENVIRONMENT_KEY)
99
+ if existing_python_path:
100
+ all_python_paths.append(existing_python_path)
101
+ child_environment[PYTHONPATH_ENVIRONMENT_KEY] = os.pathsep.join(all_python_paths)
102
+ child_environment[PYTEST_AUTO_WORKERS_ENVIRONMENT_KEY] = PYTEST_AUTO_WORKERS_LIMIT
103
+ return child_environment
104
+
105
+
106
+ def _clear_child_artifacts(registration: AdvisoryRegistration) -> bool:
107
+ try:
108
+ for each_artifact_path in (
109
+ registration.report_path,
110
+ registration.selected_manifest_path,
111
+ ):
112
+ each_artifact_path.unlink(missing_ok=True)
113
+ except OSError:
114
+ return False
115
+ return True
116
+
117
+
118
+ def _child_produced_report(
119
+ child_outcome: ChildOutcome,
120
+ registration: AdvisoryRegistration,
121
+ ) -> bool:
122
+ if child_outcome.exit_code is None or child_outcome.timed_out:
123
+ return False
124
+ if not registration.selected_manifest_path.is_file():
125
+ return False
126
+ try:
127
+ report_fields = json.loads(registration.report_path.read_text(encoding="utf-8"))
128
+ except (OSError, json.JSONDecodeError):
129
+ return False
130
+ return (
131
+ isinstance(report_fields, dict)
132
+ and report_fields.get("exit_code") == child_outcome.exit_code
133
+ )
134
+
135
+
136
+ def _start_child_process(
137
+ all_arguments: tuple[str, ...],
138
+ registration: AdvisoryRegistration,
139
+ all_child_environment: dict[str, str],
140
+ ) -> OwnedChildProcess | None:
141
+ if sys.platform == WINDOWS_PLATFORM:
142
+ return _start_windows_child(
143
+ all_arguments,
144
+ registration,
145
+ all_child_environment,
146
+ )
147
+ return _start_posix_child(
148
+ all_arguments,
149
+ registration,
150
+ all_child_environment,
151
+ )
152
+
153
+
154
+ def _start_posix_child(
155
+ all_arguments: tuple[str, ...],
156
+ registration: AdvisoryRegistration,
157
+ all_child_environment: dict[str, str],
158
+ ) -> OwnedChildProcess | None:
159
+ try:
160
+ child_process = subprocess.Popen(
161
+ all_arguments,
162
+ cwd=registration.checkout_path,
163
+ stdout=subprocess.PIPE,
164
+ stderr=subprocess.PIPE,
165
+ text=True,
166
+ env=all_child_environment,
167
+ start_new_session=True,
168
+ )
169
+ except OSError:
170
+ return None
171
+ return OwnedChildProcess(child_process, child_process.pid, None)
172
+
173
+
174
+ def _start_windows_child(
175
+ all_arguments: tuple[str, ...],
176
+ registration: AdvisoryRegistration,
177
+ all_child_environment: dict[str, str],
178
+ ) -> OwnedChildProcess | None:
179
+ maybe_job_handle = _create_kill_on_close_job()
180
+ if maybe_job_handle is None:
181
+ return None
182
+ maybe_child_process = _spawn_windows_process_host(
183
+ all_arguments,
184
+ registration,
185
+ all_child_environment,
186
+ )
187
+ if maybe_child_process is None:
188
+ _close_handle(maybe_job_handle)
189
+ return None
190
+ if not _assign_process(maybe_job_handle, maybe_child_process.pid):
191
+ _stop_unowned_process(maybe_child_process)
192
+ _close_handle(maybe_job_handle)
193
+ return None
194
+ if not _release_windows_process_host(maybe_child_process):
195
+ _terminate_job(maybe_job_handle)
196
+ _close_handle(maybe_job_handle)
197
+ return None
198
+ return OwnedChildProcess(maybe_child_process, None, maybe_job_handle)
199
+
200
+
201
+ def _spawn_windows_process_host(
202
+ all_arguments: tuple[str, ...],
203
+ registration: AdvisoryRegistration,
204
+ all_child_environment: dict[str, str],
205
+ ) -> subprocess.Popen[str] | None:
206
+ try:
207
+ return subprocess.Popen(
208
+ (
209
+ sys.executable,
210
+ PYTHON_MODULE_ARGUMENT,
211
+ WINDOWS_PROCESS_HOST_MODULE,
212
+ *all_arguments,
213
+ ),
214
+ cwd=registration.checkout_path,
215
+ stdin=subprocess.PIPE,
216
+ stdout=subprocess.PIPE,
217
+ stderr=subprocess.PIPE,
218
+ text=True,
219
+ env=all_child_environment,
220
+ creationflags=hidden_window_creation_flags()
221
+ | getattr(subprocess, WINDOWS_PRIORITY_CLASS_ATTRIBUTE, 0),
222
+ )
223
+ except OSError:
224
+ return None
225
+
226
+
227
+ def _release_windows_process_host(child_process: subprocess.Popen[str]) -> bool:
228
+ stdin_stream = child_process.stdin
229
+ if stdin_stream is None:
230
+ return False
231
+ child_process.stdin = None
232
+ try:
233
+ stdin_stream.write(WINDOWS_PROCESS_START_SIGNAL)
234
+ stdin_stream.flush()
235
+ return True
236
+ except OSError:
237
+ return False
238
+ finally:
239
+ _close_stream_without_raising(stdin_stream)
240
+
241
+
242
+ def _close_stream_without_raising(stream: IO[str]) -> None:
243
+ try:
244
+ stream.close()
245
+ except OSError:
246
+ return
247
+
248
+
249
+ def _collect_child_outcome(
250
+ owned_child: OwnedChildProcess,
251
+ timeout_seconds: float,
252
+ ) -> ChildOutcome:
253
+ child_process = owned_child.process
254
+ try:
255
+ try:
256
+ stdout_text, stderr_text = child_process.communicate(
257
+ timeout=timeout_seconds
258
+ )
259
+ except subprocess.TimeoutExpired as timeout_error:
260
+ return _stop_timed_out_child(owned_child, timeout_error)
261
+ return ChildOutcome(
262
+ child_process.returncode,
263
+ stdout_text,
264
+ stderr_text,
265
+ False,
266
+ )
267
+ finally:
268
+ _close_process_ownership(owned_child)
269
+ _close_child_streams(child_process)
270
+ _reap_child_process(child_process)
271
+
272
+
273
+ def _stop_timed_out_child(
274
+ owned_child: OwnedChildProcess,
275
+ timeout_error: subprocess.TimeoutExpired,
276
+ ) -> ChildOutcome:
277
+ child_process = owned_child.process
278
+ _terminate_owned_process_tree(owned_child)
279
+ try:
280
+ stdout_text, stderr_text = child_process.communicate(
281
+ timeout=CHILD_CLEANUP_TIMEOUT_SECONDS
282
+ )
283
+ except subprocess.TimeoutExpired as cleanup_error:
284
+ stdout_text = str(cleanup_error.stdout or timeout_error.stdout or "")
285
+ stderr_text = str(cleanup_error.stderr or timeout_error.stderr or "")
286
+ return ChildOutcome(None, str(stdout_text), str(stderr_text), True)
287
+
288
+
289
+ def _terminate_owned_process_tree(
290
+ owned_child: OwnedChildProcess,
291
+ ) -> None:
292
+ if owned_child.windows_job_handle is not None:
293
+ _terminate_job(owned_child.windows_job_handle)
294
+ if owned_child.process_group_identifier is not None:
295
+ _kill_posix_process_group(owned_child.process_group_identifier)
296
+ _kill_direct_child(owned_child.process)
297
+
298
+
299
+ def _kill_posix_process_group(process_group_identifier: int) -> None:
300
+ if sys.platform == "win32":
301
+ return
302
+ try:
303
+ os.killpg(process_group_identifier, signal.SIGKILL)
304
+ except OSError:
305
+ return
306
+
307
+
308
+ def _kill_direct_child(child_process: subprocess.Popen[str]) -> None:
309
+ if child_process.poll() is None:
310
+ try:
311
+ child_process.kill()
312
+ except ProcessLookupError:
313
+ pass
314
+
315
+
316
+ def _close_process_ownership(owned_child: OwnedChildProcess) -> None:
317
+ if owned_child.windows_job_handle is None:
318
+ return
319
+ _close_handle(owned_child.windows_job_handle)
320
+
321
+
322
+ def _close_child_streams(child_process: subprocess.Popen[str]) -> None:
323
+ for each_stream in (
324
+ child_process.stdout,
325
+ child_process.stderr,
326
+ child_process.stdin,
327
+ ):
328
+ if each_stream is None:
329
+ continue
330
+ try:
331
+ each_stream.close()
332
+ except OSError:
333
+ pass
334
+
335
+
336
+ def _stop_unowned_process(child_process: subprocess.Popen[str]) -> None:
337
+ try:
338
+ child_process.kill()
339
+ except ProcessLookupError:
340
+ return
341
+ _reap_child_process(child_process)
342
+
343
+
344
+ def _reap_child_process(child_process: subprocess.Popen[str]) -> None:
345
+ if child_process.poll() is not None:
346
+ return
347
+ try:
348
+ child_process.wait(timeout=CHILD_CLEANUP_TIMEOUT_SECONDS)
349
+ except subprocess.TimeoutExpired:
350
+ return
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ from pr_verification.model import PullRequestCandidate
7
+
8
+ from .config.constants import (
9
+ GIT_BASE_REFSPEC_TEMPLATE,
10
+ GIT_EXECUTABLE,
11
+ GIT_FETCH_ARGUMENT,
12
+ GIT_FORCE_FLAG,
13
+ GIT_NO_TAGS_FLAG,
14
+ GIT_PROMPT_DISABLED_VALUE,
15
+ GIT_REMOTE_BASE_REFSPEC_TEMPLATE,
16
+ GIT_TERMINAL_PROMPT_ENVIRONMENT_KEY,
17
+ )
18
+ from .config.timing import GIT_FETCH_TIMEOUT_SECONDS
19
+ from .model import AdvisoryRegistration
20
+ from .window_flags import hidden_window_creation_flags
21
+
22
+
23
+ def _fetch_base(
24
+ registration: AdvisoryRegistration,
25
+ candidate: PullRequestCandidate,
26
+ ) -> bool:
27
+ all_fetch_refspecs = _base_fetch_refspecs(registration, candidate)
28
+ try:
29
+ completed_process = subprocess.run(
30
+ (
31
+ GIT_EXECUTABLE,
32
+ GIT_FETCH_ARGUMENT,
33
+ GIT_FORCE_FLAG,
34
+ GIT_NO_TAGS_FLAG,
35
+ registration.remote_name,
36
+ *all_fetch_refspecs,
37
+ ),
38
+ cwd=registration.checkout_path,
39
+ capture_output=True,
40
+ check=False,
41
+ env=_build_git_environment(),
42
+ creationflags=hidden_window_creation_flags(),
43
+ timeout=GIT_FETCH_TIMEOUT_SECONDS,
44
+ )
45
+ except subprocess.TimeoutExpired:
46
+ return False
47
+ return completed_process.returncode == 0
48
+
49
+
50
+ def _base_fetch_refspecs(
51
+ registration: AdvisoryRegistration,
52
+ candidate: PullRequestCandidate,
53
+ ) -> tuple[str, str]:
54
+ return (
55
+ GIT_BASE_REFSPEC_TEMPLATE.format(
56
+ base_ref=candidate.base_ref,
57
+ pull_request=registration.pull_request_number,
58
+ ),
59
+ GIT_REMOTE_BASE_REFSPEC_TEMPLATE.format(
60
+ base_ref=candidate.base_ref,
61
+ remote_name=registration.remote_name,
62
+ ),
63
+ )
64
+
65
+
66
+ def _build_git_environment() -> dict[str, str]:
67
+ git_environment = os.environ.copy()
68
+ git_environment[GIT_TERMINAL_PROMPT_ENVIRONMENT_KEY] = GIT_PROMPT_DISABLED_VALUE
69
+ return git_environment
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from pr_verification.model import PullRequestCandidate, RepositorySettings
7
+
8
+ from .config.constants import (
9
+ ALL_TERMINAL_STATUSES,
10
+ POLL_ERROR_LOG_FILE_NAME,
11
+ POLL_LOCK_DIRECTORY_NAME,
12
+ SELECTED_MANIFEST_SUFFIX,
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AdvisoryRegistration:
18
+ repository: RepositorySettings
19
+ pull_request_number: int
20
+ checkout_path: Path
21
+ manifest_path: PurePosixPath
22
+ report_path: Path
23
+ state_path: Path
24
+ base_ref: str
25
+ remote_name: str
26
+
27
+ @property
28
+ def manifest_absolute_path(self) -> Path:
29
+ return self.checkout_path / Path(self.manifest_path)
30
+
31
+ @property
32
+ def selected_manifest_path(self) -> Path:
33
+ return self.report_path.with_name(
34
+ self.report_path.stem + SELECTED_MANIFEST_SUFFIX
35
+ )
36
+
37
+ @property
38
+ def repository_lock_root(self) -> Path:
39
+ return self.state_path.parent
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class AdvisorySettings:
44
+ api_url: str
45
+ app_id: int
46
+ installation_id: int
47
+ private_key_path: Path
48
+ poll_seconds: float
49
+ child_timeout_seconds: float
50
+ registrations: tuple[AdvisoryRegistration, ...]
51
+
52
+ @property
53
+ def poll_lock_root(self) -> Path:
54
+ return self.registrations[0].state_path.parent / POLL_LOCK_DIRECTORY_NAME
55
+
56
+ @property
57
+ def poll_error_log_path(self) -> Path:
58
+ return self.registrations[0].state_path.parent / POLL_ERROR_LOG_FILE_NAME
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class AdvisoryState:
63
+ repository: str
64
+ pull_request_number: int
65
+ status: str
66
+ reason: str
67
+ head_sha: str | None
68
+ base_sha: str | None
69
+ last_poll: str
70
+ last_run: str | None
71
+ report_path: str
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ChildOutcome:
76
+ exit_code: int | None
77
+ stdout_text: str
78
+ stderr_text: str
79
+ timed_out: bool
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class LocalCheckout:
84
+ head_sha: str | None
85
+ is_clean: bool
86
+
87
+
88
+ def candidate_identity_matches_state(
89
+ candidate: PullRequestCandidate,
90
+ maybe_state: AdvisoryState | None,
91
+ ) -> bool:
92
+ """Return whether a terminal state already covers the candidate identity.
93
+
94
+ Args:
95
+ candidate: Current remote pull request identity.
96
+ maybe_state: Recorded advisory state.
97
+
98
+ Returns:
99
+ True when the same head and base already reached a terminal state.
100
+ """
101
+ if maybe_state is None:
102
+ return False
103
+ return (
104
+ maybe_state.head_sha == candidate.head_sha
105
+ and maybe_state.base_sha == candidate.base_sha
106
+ and maybe_state.status in ALL_TERMINAL_STATUSES
107
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from collections.abc import Sequence
6
+
7
+ from pr_verification.config.constants import INCOMPLETE_EXIT_CODE
8
+
9
+ from .config.constants import WINDOWS_PROCESS_START_SIGNAL
10
+
11
+
12
+ def run_owned_child(all_arguments: Sequence[str]) -> int:
13
+ """Wait for ownership, then run the advisory child.
14
+
15
+ Args:
16
+ all_arguments: Child executable and arguments.
17
+
18
+ Returns:
19
+ Child exit code or the incomplete exit code when ownership is absent.
20
+ """
21
+ if sys.stdin.read(1) != WINDOWS_PROCESS_START_SIGNAL:
22
+ return INCOMPLETE_EXIT_CODE
23
+ completed_process = subprocess.run(
24
+ tuple(all_arguments),
25
+ shell=False,
26
+ check=False,
27
+ )
28
+ return completed_process.returncode
29
+
30
+
31
+ if __name__ == "__main__":
32
+ raise SystemExit(run_owned_child(sys.argv[1:]))
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from importlib import import_module
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from pr_verification.config.constants import STATUS_FAILURE, STATUS_SUCCESS
10
+ from pr_verification.model import RepositorySettings
11
+
12
+ from .config.constants import STATE_STATUS_FAILED, STATE_STATUS_PASSED
13
+
14
+
15
+ class Publication(Protocol):
16
+ @property
17
+ def status(self) -> str: ...
18
+
19
+ @property
20
+ def description(self) -> str: ...
21
+
22
+
23
+ class PublicationStatus(Protocol):
24
+ @property
25
+ def value(self) -> str: ...
26
+
27
+
28
+ class PublisherOutcome(Protocol):
29
+ @property
30
+ def status(self) -> PublicationStatus: ...
31
+
32
+ @property
33
+ def description(self) -> str: ...
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class PublicationRecord:
38
+ status: str
39
+ description: str
40
+
41
+
42
+ def _publish_report(
43
+ github: object,
44
+ repository: RepositorySettings,
45
+ pull_request_number: int,
46
+ checkout_path: Path,
47
+ manifest_path: Path,
48
+ report_path: Path,
49
+ ) -> Publication:
50
+ publisher_module = import_module("local_report_publisher")
51
+ publisher_function: Callable[..., PublisherOutcome] = (
52
+ publisher_module.publish_local_report
53
+ )
54
+ publication_outcome = publisher_function(
55
+ github,
56
+ repository,
57
+ pull_request_number,
58
+ checkout_path,
59
+ manifest_path,
60
+ report_path,
61
+ )
62
+ publication_status = publication_outcome.status
63
+ publication_description = publication_outcome.description
64
+ return PublicationRecord(
65
+ _normalize_publication_status(publication_status),
66
+ publication_description,
67
+ )
68
+
69
+
70
+ def _normalize_publication_status(publication_status: PublicationStatus) -> str:
71
+ status_text = publication_status.value
72
+ if status_text == STATUS_SUCCESS:
73
+ return STATUS_SUCCESS
74
+ if status_text == STATUS_FAILURE:
75
+ return STATUS_FAILURE
76
+ return status_text
77
+
78
+
79
+ def _cache_status_for_publication(publication_status: str) -> str:
80
+ if publication_status == STATUS_SUCCESS:
81
+ return STATE_STATUS_PASSED
82
+ if publication_status == STATUS_FAILURE:
83
+ return STATE_STATUS_FAILED
84
+ return publication_status