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,364 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import TypeAlias
7
+
8
+ from .check_support import (
9
+ _prepare_check_invocation,
10
+ _record_missing_check,
11
+ _write_logs,
12
+ )
13
+ from .command_runner import run_command as _run_command
14
+ from .config import (
15
+ CLI_CHECK_FINISH_MESSAGE_TEMPLATE,
16
+ CLI_CHECK_START_MESSAGE_TEMPLATE,
17
+ COLLECTED_TESTS_PATTERN,
18
+ COLLECTION_ERROR_KIND,
19
+ COLLECTION_STDERR_LOG_SUFFIX,
20
+ COLLECTION_STDOUT_LOG_SUFFIX,
21
+ CRASH_ERROR_KIND,
22
+ FAILED_STATUS,
23
+ INCOMPLETE_STATUS,
24
+ MINIMUM_TESTS_ERROR_KIND,
25
+ NONZERO_EXIT_ERROR_KIND,
26
+ PASSED_STATUS,
27
+ PYTEST_COLLECTION_FLAG,
28
+ PYTEST_QUIET_FLAG,
29
+ SAFE_LOG_NAME_PATTERN,
30
+ SKIPPED_TESTS_ERROR_KIND,
31
+ SKIPPED_TESTS_PATTERN,
32
+ STDERR_LOG_SUFFIX,
33
+ STDOUT_LOG_SUFFIX,
34
+ SUCCESS_EXIT_CODE,
35
+ )
36
+ from .model import (
37
+ CheckLogPaths,
38
+ CheckRecord,
39
+ CheckSpec,
40
+ CommandCapture,
41
+ VerificationManifest,
42
+ )
43
+
44
+ ProgressCallback: TypeAlias = Callable[[str], None]
45
+
46
+
47
+ def execute_all_checks(
48
+ manifest: VerificationManifest,
49
+ repository_path: Path,
50
+ base_revision: str,
51
+ logs_directory: Path,
52
+ progress_callback: ProgressCallback | None,
53
+ ) -> tuple[CheckRecord, ...]:
54
+ """Execute every check in the manifest.
55
+
56
+ Args:
57
+ manifest: Checks to execute.
58
+
59
+ Returns:
60
+ One record for each check.
61
+ """
62
+ return tuple(
63
+ _execute_check(
64
+ each_check,
65
+ repository_path,
66
+ base_revision,
67
+ logs_directory,
68
+ progress_callback,
69
+ )
70
+ for each_check in manifest.checks
71
+ )
72
+
73
+
74
+ def _execute_check(
75
+ check: CheckSpec,
76
+ repository_path: Path,
77
+ base_revision: str,
78
+ logs_directory: Path,
79
+ progress_callback: ProgressCallback | None,
80
+ ) -> CheckRecord:
81
+ _emit_progress(
82
+ progress_callback,
83
+ CLI_CHECK_START_MESSAGE_TEMPLATE.format(check_id=check.check_id),
84
+ )
85
+ check_log_paths = _build_check_log_paths(check, logs_directory)
86
+ check_directory, all_arguments = _prepare_check_invocation(
87
+ check, repository_path, base_revision
88
+ )
89
+ if not check_directory.is_dir():
90
+ check_record = _record_missing_check(
91
+ check, all_arguments, check_directory, check_log_paths
92
+ )
93
+ return _finish_with_progress(progress_callback, check, check_record)
94
+ check_record = _run_existing_check(
95
+ check, all_arguments, check_directory, check_log_paths
96
+ )
97
+ return _finish_with_progress(progress_callback, check, check_record)
98
+
99
+
100
+ def _build_check_log_paths(check: CheckSpec, logs_directory: Path) -> CheckLogPaths:
101
+ safe_check_id = SAFE_LOG_NAME_PATTERN.sub("_", check.check_id)
102
+ return CheckLogPaths(
103
+ logs_directory / (safe_check_id + STDOUT_LOG_SUFFIX),
104
+ logs_directory / (safe_check_id + STDERR_LOG_SUFFIX),
105
+ logs_directory / (safe_check_id + COLLECTION_STDOUT_LOG_SUFFIX),
106
+ logs_directory / (safe_check_id + COLLECTION_STDERR_LOG_SUFFIX),
107
+ )
108
+
109
+
110
+ def _run_existing_check(
111
+ check: CheckSpec,
112
+ all_arguments: tuple[str, ...],
113
+ check_directory: Path,
114
+ check_log_paths: CheckLogPaths,
115
+ ) -> CheckRecord:
116
+ all_collection_capture = _collect_minimum_tests(
117
+ check,
118
+ all_arguments,
119
+ check_directory,
120
+ check_log_paths.collection_stdout,
121
+ check_log_paths.collection_stderr,
122
+ )
123
+ started_at = time.monotonic()
124
+ command_capture = _run_command(
125
+ all_arguments, check_directory, check.timeout_seconds
126
+ )
127
+ duration_seconds = time.monotonic() - started_at
128
+ return _finish_check(
129
+ check,
130
+ all_arguments,
131
+ check_directory,
132
+ check_log_paths,
133
+ all_collection_capture,
134
+ command_capture,
135
+ duration_seconds,
136
+ )
137
+
138
+
139
+ def _finish_with_progress(
140
+ progress_callback: ProgressCallback | None,
141
+ check: CheckSpec,
142
+ check_record: CheckRecord,
143
+ ) -> CheckRecord:
144
+ _emit_progress(
145
+ progress_callback,
146
+ CLI_CHECK_FINISH_MESSAGE_TEMPLATE.format(
147
+ check_id=check.check_id, status=check_record.status
148
+ ),
149
+ )
150
+ return check_record
151
+
152
+
153
+ def _emit_progress(
154
+ progress_callback: ProgressCallback | None, progress_message: str
155
+ ) -> None:
156
+ if progress_callback is not None:
157
+ progress_callback(progress_message)
158
+
159
+
160
+ def _finish_check(
161
+ check: CheckSpec,
162
+ all_arguments: tuple[str, ...],
163
+ check_directory: Path,
164
+ check_log_paths: CheckLogPaths,
165
+ all_collection_capture: tuple[CommandCapture, int | None] | None,
166
+ command_capture: CommandCapture,
167
+ duration_seconds: float,
168
+ ) -> CheckRecord:
169
+ _write_logs(
170
+ check_log_paths.stdout,
171
+ check_log_paths.stderr,
172
+ command_capture.stdout_text,
173
+ command_capture.stderr_text,
174
+ )
175
+ return _build_finished_record(
176
+ check,
177
+ all_arguments,
178
+ check_directory,
179
+ check_log_paths,
180
+ all_collection_capture,
181
+ command_capture,
182
+ duration_seconds,
183
+ )
184
+
185
+
186
+ def _build_finished_record(
187
+ check: CheckSpec,
188
+ all_arguments: tuple[str, ...],
189
+ check_directory: Path,
190
+ check_log_paths: CheckLogPaths,
191
+ all_collection_capture: tuple[CommandCapture, int | None] | None,
192
+ command_capture: CommandCapture,
193
+ duration_seconds: float,
194
+ ) -> CheckRecord:
195
+ status, error_kind, error_message = _classify_record(
196
+ check, all_collection_capture, command_capture
197
+ )
198
+ return CheckRecord(
199
+ check.check_id,
200
+ status,
201
+ all_arguments,
202
+ str(check_directory),
203
+ command_capture.exit_code,
204
+ duration_seconds,
205
+ check_log_paths.stdout,
206
+ check_log_paths.stderr,
207
+ error_kind,
208
+ error_message,
209
+ check.minimum_tests,
210
+ all_collection_capture[1] if all_collection_capture is not None else None,
211
+ all_collection_capture[0].exit_code
212
+ if all_collection_capture is not None
213
+ else None,
214
+ )
215
+
216
+
217
+ def _classify_record(
218
+ check: CheckSpec,
219
+ all_collection_capture: tuple[CommandCapture, int | None] | None,
220
+ command_capture: CommandCapture,
221
+ ) -> tuple[str, str | None, str | None]:
222
+ status, error_kind, error_message = _classify_command(command_capture)
223
+ if all_collection_capture is None:
224
+ return status, error_kind, error_message
225
+ status, error_kind, error_message = _classify_collection(
226
+ check, all_collection_capture, status, error_kind, error_message
227
+ )
228
+ return _classify_skipped_tests(
229
+ check,
230
+ all_collection_capture,
231
+ command_capture,
232
+ status,
233
+ error_kind,
234
+ error_message,
235
+ )
236
+
237
+
238
+ def _collect_minimum_tests(
239
+ check: CheckSpec,
240
+ all_arguments: tuple[str, ...],
241
+ check_directory: Path,
242
+ stdout_log_path: Path,
243
+ stderr_log_path: Path,
244
+ ) -> tuple[CommandCapture, int | None] | None:
245
+ if check.minimum_tests is None:
246
+ return None
247
+ collection_arguments = _collection_arguments(all_arguments)
248
+ collection_capture = _run_command(
249
+ collection_arguments, check_directory, check.timeout_seconds
250
+ )
251
+ _write_logs(
252
+ stdout_log_path,
253
+ stderr_log_path,
254
+ collection_capture.stdout_text,
255
+ collection_capture.stderr_text,
256
+ )
257
+ collected_tests = _parse_collected_tests(collection_capture)
258
+ return collection_capture, collected_tests
259
+
260
+
261
+ def _collection_arguments(all_arguments: tuple[str, ...]) -> tuple[str, ...]:
262
+ all_collection_arguments = list(all_arguments)
263
+ if PYTEST_COLLECTION_FLAG not in all_collection_arguments:
264
+ all_collection_arguments.append(PYTEST_COLLECTION_FLAG)
265
+ if PYTEST_QUIET_FLAG not in all_collection_arguments:
266
+ all_collection_arguments.append(PYTEST_QUIET_FLAG)
267
+ return tuple(all_collection_arguments)
268
+
269
+
270
+ def _parse_collected_tests(command_capture: CommandCapture) -> int | None:
271
+ all_text = command_capture.stdout_text + command_capture.stderr_text
272
+ test_match = COLLECTED_TESTS_PATTERN.search(all_text)
273
+ if test_match is None:
274
+ return 0 if command_capture.exit_code == 5 else None
275
+ return int(test_match.group("count"))
276
+
277
+
278
+ def _parse_skipped_tests(command_capture: CommandCapture) -> int:
279
+ all_text = command_capture.stdout_text + command_capture.stderr_text
280
+ skipped_match = SKIPPED_TESTS_PATTERN.search(all_text)
281
+ if skipped_match is None:
282
+ return 0
283
+ return int(skipped_match.group("count"))
284
+
285
+
286
+ def _classify_skipped_tests(
287
+ check: CheckSpec,
288
+ all_collection_capture: tuple[CommandCapture, int | None],
289
+ command_capture: CommandCapture,
290
+ status: str,
291
+ error_kind: str | None,
292
+ error_message: str | None,
293
+ ) -> tuple[str, str | None, str | None]:
294
+ if status != PASSED_STATUS:
295
+ return status, error_kind, error_message
296
+ if not _has_skipped_required_tests(check, all_collection_capture, command_capture):
297
+ return status, error_kind, error_message
298
+ return INCOMPLETE_STATUS, SKIPPED_TESTS_ERROR_KIND, "Required tests were skipped"
299
+
300
+
301
+ def _has_skipped_required_tests(
302
+ check: CheckSpec,
303
+ all_collection_capture: tuple[CommandCapture, int | None],
304
+ command_capture: CommandCapture,
305
+ ) -> bool:
306
+ if check.minimum_tests is None:
307
+ return False
308
+ collection_capture, collected_tests = all_collection_capture
309
+ if collection_capture.exit_code != SUCCESS_EXIT_CODE:
310
+ return False
311
+ if collected_tests is None or collected_tests == 0:
312
+ return False
313
+ return _parse_skipped_tests(command_capture) > 0
314
+
315
+
316
+ def _classify_collection(
317
+ check: CheckSpec,
318
+ all_collection_capture: tuple[CommandCapture, int | None],
319
+ status: str,
320
+ error_kind: str | None,
321
+ error_message: str | None,
322
+ ) -> tuple[str, str | None, str | None]:
323
+ command_capture, collected_tests = all_collection_capture
324
+ if (
325
+ check.minimum_tests is not None
326
+ and collected_tests is not None
327
+ and collected_tests < check.minimum_tests
328
+ ):
329
+ return FAILED_STATUS, MINIMUM_TESTS_ERROR_KIND, "Minimum test count was not met"
330
+ if status == FAILED_STATUS:
331
+ return status, error_kind, error_message
332
+ if command_capture.error_kind is not None:
333
+ return INCOMPLETE_STATUS, COLLECTION_ERROR_KIND, command_capture.error_message
334
+ if command_capture.exit_code != SUCCESS_EXIT_CODE:
335
+ return INCOMPLETE_STATUS, COLLECTION_ERROR_KIND, "Pytest collection failed"
336
+ if collected_tests is None:
337
+ return (
338
+ INCOMPLETE_STATUS,
339
+ COLLECTION_ERROR_KIND,
340
+ "Pytest collection count is missing",
341
+ )
342
+ return status, error_kind, error_message
343
+
344
+
345
+ def _classify_command(
346
+ command_capture: CommandCapture,
347
+ ) -> tuple[str, str | None, str | None]:
348
+ if command_capture.error_kind is not None:
349
+ return (
350
+ INCOMPLETE_STATUS,
351
+ command_capture.error_kind,
352
+ command_capture.error_message,
353
+ )
354
+ if command_capture.exit_code is None:
355
+ return INCOMPLETE_STATUS, CRASH_ERROR_KIND, "Command ended without an exit code"
356
+ if command_capture.exit_code < 0:
357
+ return INCOMPLETE_STATUS, CRASH_ERROR_KIND, "Command ended from a signal"
358
+ if command_capture.exit_code != SUCCESS_EXIT_CODE:
359
+ return (
360
+ FAILED_STATUS,
361
+ NONZERO_EXIT_ERROR_KIND,
362
+ "Command returned a nonzero exit code",
363
+ )
364
+ return PASSED_STATUS, None, None
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .config import (
7
+ BASE_PLACEHOLDER,
8
+ CDE_LINT_PLACEHOLDER,
9
+ INCOMPLETE_STATUS,
10
+ PYTHON_PLACEHOLDER,
11
+ REPOSITORY_PLACEHOLDER,
12
+ REPOSITORY_POLICY_PLACEHOLDER,
13
+ START_ERROR_KIND,
14
+ UTF8_ENCODING,
15
+ )
16
+ from .model import CheckLogPaths, CheckRecord, CheckSpec
17
+
18
+
19
+ def _prepare_check_invocation(
20
+ check: CheckSpec, repository_path: Path, base_revision: str
21
+ ) -> tuple[Path, tuple[str, ...]]:
22
+ check_directory = repository_path / check.cwd
23
+ all_arguments = _substitute_arguments(
24
+ check.command_arguments, repository_path, base_revision
25
+ )
26
+ return check_directory, all_arguments
27
+
28
+
29
+ def _record_missing_check(
30
+ check: CheckSpec,
31
+ all_arguments: tuple[str, ...],
32
+ check_directory: Path,
33
+ check_log_paths: CheckLogPaths,
34
+ ) -> CheckRecord:
35
+ _write_logs(
36
+ check_log_paths.stdout, check_log_paths.stderr, "", "Missing check directory"
37
+ )
38
+ return _incomplete_record(
39
+ check,
40
+ all_arguments,
41
+ check_directory,
42
+ check_log_paths.stdout,
43
+ check_log_paths.stderr,
44
+ START_ERROR_KIND,
45
+ "Check cwd does not exist",
46
+ )
47
+
48
+
49
+ def _substitute_arguments(
50
+ all_arguments: tuple[str, ...], repository_path: Path, base_revision: str
51
+ ) -> tuple[str, ...]:
52
+ package_root = Path(__file__).resolve().parents[2]
53
+ replacement_by_placeholder = {
54
+ PYTHON_PLACEHOLDER: sys.executable,
55
+ REPOSITORY_PLACEHOLDER: str(repository_path),
56
+ BASE_PLACEHOLDER: base_revision,
57
+ CDE_LINT_PLACEHOLDER: str(package_root / "scripts" / "cde_lint.py"),
58
+ REPOSITORY_POLICY_PLACEHOLDER: str(
59
+ package_root / "scripts" / "repository_policy.py"
60
+ ),
61
+ }
62
+ return tuple(
63
+ replacement_by_placeholder.get(each_argument, each_argument)
64
+ for each_argument in all_arguments
65
+ )
66
+
67
+
68
+ def _write_logs(
69
+ stdout_log_path: Path,
70
+ stderr_log_path: Path,
71
+ stdout_text: str,
72
+ stderr_text: str,
73
+ ) -> None:
74
+ stdout_log_path.write_text(stdout_text, encoding=UTF8_ENCODING)
75
+ stderr_log_path.write_text(stderr_text, encoding=UTF8_ENCODING)
76
+
77
+
78
+ def _incomplete_record(
79
+ check: CheckSpec,
80
+ all_arguments: tuple[str, ...],
81
+ check_directory: Path,
82
+ stdout_log_path: Path,
83
+ stderr_log_path: Path,
84
+ error_kind: str,
85
+ error_message: str,
86
+ ) -> CheckRecord:
87
+ return CheckRecord(
88
+ check.check_id,
89
+ INCOMPLETE_STATUS,
90
+ all_arguments,
91
+ str(check_directory),
92
+ None,
93
+ 0.0,
94
+ stdout_log_path,
95
+ stderr_log_path,
96
+ error_kind,
97
+ error_message,
98
+ check.minimum_tests,
99
+ )
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import Never, TextIO
8
+
9
+ scripts_directory = Path(__file__).resolve().parents[1]
10
+ if str(scripts_directory) not in sys.path:
11
+ sys.path.insert(0, str(scripts_directory))
12
+
13
+ from local_verification.config import (
14
+ CLI_AGGREGATE_MESSAGE_TEMPLATE,
15
+ CLI_ELIGIBILITY_MESSAGE_TEMPLATE,
16
+ CLI_FAILURE_MESSAGE_TEMPLATE,
17
+ CLI_REPORT_MESSAGE_TEMPLATE,
18
+ CLI_REVISION_MESSAGE_TEMPLATE,
19
+ CLI_START_MESSAGE_TEMPLATE,
20
+ INCOMPLETE_EXIT_CODE,
21
+ INVALID_INPUT_EXIT_CODE,
22
+ PASSED_STATUS,
23
+ REPORT_NEWLINE,
24
+ UNRESOLVED_REVISION,
25
+ )
26
+ from local_verification.manifest import ManifestRunFatal, load_manifest
27
+ from local_verification.model import VerificationManifest, VerificationReport
28
+ from local_verification.runner import run_verification
29
+
30
+
31
+ class _VerificationArgumentParser(argparse.ArgumentParser):
32
+ def error(self, message: str) -> Never:
33
+ raise ManifestRunFatal(message)
34
+
35
+
36
+ def main(
37
+ all_arguments: Sequence[str],
38
+ *,
39
+ stdout: TextIO = sys.stdout,
40
+ stderr: TextIO = sys.stderr,
41
+ ) -> int:
42
+ """Run the local verification manifest.
43
+ Args:
44
+ all_arguments: Command arguments.
45
+ Returns:
46
+ The verifier exit code.
47
+ """
48
+ parser = _build_parser()
49
+ try:
50
+ parsed_arguments = parser.parse_args(all_arguments)
51
+ except ManifestRunFatal as error:
52
+ stderr.write(f"{error}{REPORT_NEWLINE}")
53
+ return INVALID_INPUT_EXIT_CODE
54
+ report_path = Path(parsed_arguments.output)
55
+ if not _clear_previous_report(report_path, stderr):
56
+ return INCOMPLETE_EXIT_CODE
57
+ _write_progress(stdout, CLI_START_MESSAGE_TEMPLATE.format(report_path=report_path))
58
+ manifest = _load_manifest_for_cli(parsed_arguments.manifest, stderr)
59
+ if manifest is None:
60
+ return INVALID_INPUT_EXIT_CODE
61
+ report = _run_manifest_verification(manifest, parsed_arguments, stdout, stderr)
62
+ if report is None:
63
+ return INCOMPLETE_EXIT_CODE
64
+ _write_report_summary(report, report_path, stdout, stderr)
65
+ return report.exit_code
66
+
67
+
68
+ def _build_parser() -> argparse.ArgumentParser:
69
+ parser = _VerificationArgumentParser(prog="cde verify", add_help=True)
70
+ parser.add_argument("--manifest", required=True)
71
+ parser.add_argument("--repo", dest="repository", required=True)
72
+ parser.add_argument("--base", dest="base_revision", required=True)
73
+ parser.add_argument("--output", required=True)
74
+ return parser
75
+
76
+
77
+ def _run_manifest_verification(
78
+ manifest: VerificationManifest,
79
+ parsed_arguments: argparse.Namespace,
80
+ stdout: TextIO,
81
+ stderr: TextIO,
82
+ ) -> VerificationReport | None:
83
+ try:
84
+ return run_verification(
85
+ manifest,
86
+ Path(parsed_arguments.repository),
87
+ parsed_arguments.base_revision,
88
+ Path(parsed_arguments.output),
89
+ progress_callback=lambda progress_message: _write_progress(
90
+ stdout, progress_message
91
+ ),
92
+ )
93
+ except (OSError, ValueError) as error:
94
+ stderr.write(f"{error}{REPORT_NEWLINE}")
95
+ return None
96
+
97
+
98
+ def _clear_previous_report(report_path: Path, stderr: TextIO) -> bool:
99
+ try:
100
+ report_path.unlink(missing_ok=True)
101
+ except OSError as error:
102
+ stderr.write(f"{error}{REPORT_NEWLINE}")
103
+ return False
104
+ return True
105
+
106
+
107
+ def _write_progress(progress_stream: TextIO, progress_message: str) -> None:
108
+ progress_stream.write(f"{progress_message}{REPORT_NEWLINE}")
109
+ progress_stream.flush()
110
+
111
+
112
+ def _write_report_summary(
113
+ report: VerificationReport,
114
+ report_path: Path,
115
+ stdout: TextIO,
116
+ stderr: TextIO,
117
+ ) -> None:
118
+ _write_progress(
119
+ stdout, CLI_AGGREGATE_MESSAGE_TEMPLATE.format(status=report.aggregate_status)
120
+ )
121
+ _write_progress(
122
+ stdout,
123
+ CLI_REVISION_MESSAGE_TEMPLATE.format(
124
+ head=_format_revision(report.head_revision),
125
+ base=_format_revision(report.base_revision),
126
+ ),
127
+ )
128
+ _write_progress(
129
+ stdout,
130
+ CLI_ELIGIBILITY_MESSAGE_TEMPLATE.format(
131
+ worktree_clean=str(report.worktree_clean).lower(),
132
+ inputs_unchanged=str(report.inputs_unchanged).lower(),
133
+ publishable=str(report.publishable).lower(),
134
+ ),
135
+ )
136
+ _write_progress(stdout, CLI_REPORT_MESSAGE_TEMPLATE.format(report_path=report_path))
137
+ if report.aggregate_status != PASSED_STATUS:
138
+ stderr.write(
139
+ f"{CLI_FAILURE_MESSAGE_TEMPLATE.format(status=report.aggregate_status)}{REPORT_NEWLINE}"
140
+ )
141
+
142
+
143
+ def _format_revision(revision: str | None) -> str:
144
+ return revision or UNRESOLVED_REVISION
145
+
146
+
147
+ def _load_manifest_for_cli(
148
+ manifest_name: str, stderr: TextIO
149
+ ) -> VerificationManifest | None:
150
+ try:
151
+ return load_manifest(Path(manifest_name))
152
+ except (ManifestRunFatal, OSError) as error:
153
+ stderr.write(f"{error}{REPORT_NEWLINE}")
154
+ return None
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .config import (
7
+ MISSING_TOOL_ERROR_KIND,
8
+ START_ERROR_KIND,
9
+ TIMEOUT_ERROR_KIND,
10
+ UTF8_ENCODING,
11
+ )
12
+ from .model import CommandCapture
13
+
14
+
15
+ def run_command(
16
+ all_arguments: tuple[str, ...], check_directory: Path, timeout_seconds: float
17
+ ) -> CommandCapture:
18
+ """Run a local command and return its captured state.
19
+
20
+ Args:
21
+ all_arguments: Command and its arguments.
22
+ check_directory: Working directory for the command.
23
+ timeout_seconds: Wall-clock limit for the command.
24
+
25
+ Returns:
26
+ The captured exit code, output, and error kind.
27
+ """
28
+ try:
29
+ completed_process = _run_subprocess(
30
+ all_arguments, check_directory, timeout_seconds
31
+ )
32
+ except FileNotFoundError as error:
33
+ return CommandCapture(None, "", "", MISSING_TOOL_ERROR_KIND, str(error))
34
+ except subprocess.TimeoutExpired as error:
35
+ return _timeout_capture(error)
36
+ except (OSError, ValueError) as error:
37
+ return CommandCapture(None, "", "", START_ERROR_KIND, str(error))
38
+ return _completed_capture(completed_process)
39
+
40
+
41
+ def _timeout_capture(error: subprocess.TimeoutExpired) -> CommandCapture:
42
+ return CommandCapture(
43
+ None,
44
+ _decode_command_text(error.stdout),
45
+ _decode_command_text(error.stderr),
46
+ TIMEOUT_ERROR_KIND,
47
+ "Command exceeded its timeout",
48
+ )
49
+
50
+
51
+ def _completed_capture(
52
+ completed_process: subprocess.CompletedProcess[str],
53
+ ) -> CommandCapture:
54
+ return CommandCapture(
55
+ completed_process.returncode,
56
+ completed_process.stdout,
57
+ completed_process.stderr,
58
+ None,
59
+ None,
60
+ )
61
+
62
+
63
+ def _run_subprocess(
64
+ all_arguments: tuple[str, ...], check_directory: Path, timeout_seconds: float
65
+ ) -> subprocess.CompletedProcess[str]:
66
+ return subprocess.run(
67
+ all_arguments,
68
+ cwd=check_directory,
69
+ shell=False,
70
+ check=False,
71
+ capture_output=True,
72
+ text=True,
73
+ encoding=UTF8_ENCODING,
74
+ errors="replace",
75
+ timeout=timeout_seconds,
76
+ )
77
+
78
+
79
+ def _decode_command_text(command_text: str | bytes | None) -> str:
80
+ if command_text is None:
81
+ return ""
82
+ if isinstance(command_text, bytes):
83
+ return command_text.decode(UTF8_ENCODING, errors="replace")
84
+ return command_text
@@ -0,0 +1 @@
1
+ from .constants import *