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,400 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from types import SimpleNamespace
11
+
12
+ import pytest
13
+
14
+ TESTS_DIRECTORY = Path(__file__).resolve().parent
15
+ if str(TESTS_DIRECTORY) not in sys.path:
16
+ sys.path.insert(0, str(TESTS_DIRECTORY))
17
+
18
+ import test_automatic_advisory as support
19
+ from automatic_advisory import cli, git, publisher
20
+ from pr_verification.model import RepositorySettings, StatusState
21
+
22
+ PROCESS_RUNNER_TIMEOUT_SECONDS = 10.0
23
+ PRODUCTION_CHILD_TIMEOUT_SECONDS = 3.0
24
+ PROCESS_EXIT_WAIT_SECONDS = 2.0
25
+ PROCESS_POLL_SECONDS = 0.05
26
+
27
+
28
+ class _SuccessfulPublicationAdapter:
29
+ status = StatusState.SUCCESS
30
+ description = "passed"
31
+
32
+
33
+ def _publisher_module_recording_paths(
34
+ all_published_manifest_paths: list[Path],
35
+ ) -> SimpleNamespace:
36
+ def record_publication(
37
+ _github: object,
38
+ _repository: RepositorySettings,
39
+ _pull_request_number: int,
40
+ _checkout_path: Path,
41
+ manifest_path: Path,
42
+ _report_path: Path,
43
+ ) -> _SuccessfulPublicationAdapter:
44
+ all_published_manifest_paths.append(manifest_path)
45
+ return _SuccessfulPublicationAdapter()
46
+
47
+ return SimpleNamespace(publish_local_report=record_publication)
48
+
49
+
50
+ def _build_counted_runner(
51
+ registration: support.AdvisoryRegistration,
52
+ github: support.FakeGitHub,
53
+ child_calls: list[str],
54
+ ) -> support.AutomaticAdvisoryRunner:
55
+ def run_child(
56
+ child_registration: support.AdvisoryRegistration,
57
+ base_ref: str,
58
+ timeout_seconds: float,
59
+ ) -> support.ChildOutcome:
60
+ return support._run_counted_child(
61
+ child_calls,
62
+ child_registration,
63
+ base_ref,
64
+ timeout_seconds,
65
+ )
66
+
67
+ return support.AutomaticAdvisoryRunner(
68
+ support._build_settings(registration),
69
+ github,
70
+ child_runner=run_child,
71
+ clock=lambda: "2026-09-05T00:00:00+00:00",
72
+ )
73
+
74
+
75
+ def _build_recovering_cli_runner(
76
+ monkeypatch: pytest.MonkeyPatch,
77
+ registration: support.AdvisoryRegistration,
78
+ github: support.FakeGitHub,
79
+ authentication_attempts: list[str],
80
+ child_calls: list[str],
81
+ ) -> support.AutomaticAdvisoryRunner:
82
+ def issue_repository_api(*arguments: object, **options: object) -> object:
83
+ authentication_attempts.append("attempt")
84
+ if len(authentication_attempts) == 1:
85
+ raise RuntimeError("offline")
86
+ return github
87
+
88
+ monkeypatch.setattr(cli, "_issue_repository_api", issue_repository_api)
89
+ runner = cli._build_runner(support._build_settings(registration))
90
+ counted_runner = _build_counted_runner(registration, github, child_calls)
91
+ runner.child_runner = counted_runner.child_runner
92
+ runner.publisher = support._publish_passed
93
+ return runner
94
+
95
+
96
+ def _run_report_only_child(
97
+ registration: support.AdvisoryRegistration,
98
+ ) -> support.ChildOutcome:
99
+ registration.report_path.parent.mkdir(parents=True, exist_ok=True)
100
+ registration.report_path.write_text(
101
+ json.dumps({"exit_code": 0}),
102
+ encoding="utf-8",
103
+ )
104
+ return support.ChildOutcome(0, "", "", False)
105
+
106
+
107
+ def _record_unexpected_publication(
108
+ publisher_calls: list[str],
109
+ *all_arguments: object,
110
+ ) -> support.Publication:
111
+ publisher_calls.append("publish")
112
+ return support.FakePublication("passed")
113
+
114
+
115
+ def test_publisher_adapter_returns_github_status_text(
116
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
117
+ ) -> None:
118
+ class PublicationAdapter:
119
+ status = StatusState.SUCCESS
120
+ description = "passed"
121
+
122
+ class PublisherModule:
123
+ publish_local_report = staticmethod(lambda *arguments: PublicationAdapter())
124
+
125
+ monkeypatch.setattr(publisher, "import_module", lambda name: PublisherModule)
126
+ registration = support._build_registration(tmp_path, tmp_path)
127
+ publication = publisher._publish_report(
128
+ object(),
129
+ registration.repository,
130
+ registration.pull_request_number,
131
+ registration.checkout_path,
132
+ registration.manifest_absolute_path,
133
+ registration.report_path,
134
+ )
135
+
136
+ assert publication.status == "success"
137
+
138
+
139
+ def test_real_publisher_adapter_runs_once_for_unchanged_identity(
140
+ monkeypatch: pytest.MonkeyPatch,
141
+ tmp_path: Path,
142
+ ) -> None:
143
+ all_published_manifest_paths: list[Path] = []
144
+ publisher_module = _publisher_module_recording_paths(all_published_manifest_paths)
145
+ monkeypatch.setattr(publisher, "import_module", lambda name: publisher_module)
146
+ checkout_path, head_sha = support._build_checkout(tmp_path)
147
+ registration = support._build_registration(tmp_path, checkout_path)
148
+ github = support.FakeGitHub(support._build_candidate(head_sha))
149
+ child_calls: list[str] = []
150
+ runner = _build_counted_runner(registration, github, child_calls)
151
+ first_state = runner.run_once()[0]
152
+ second_state = runner.run_once()[0]
153
+
154
+ assert first_state.status == "passed"
155
+ assert second_state.status == "passed"
156
+ assert child_calls == ["child"]
157
+ assert all_published_manifest_paths == [registration.selected_manifest_path]
158
+
159
+
160
+ def test_build_runner_run_once_persists_offline_then_recovers(
161
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
162
+ ) -> None:
163
+ checkout_path, head_sha = support._build_checkout(tmp_path)
164
+ registration = support._build_registration(tmp_path, checkout_path)
165
+ github = support.FakeGitHub(support._build_candidate(head_sha))
166
+ authentication_attempts: list[str] = []
167
+ child_calls: list[str] = []
168
+ runner = _build_recovering_cli_runner(
169
+ monkeypatch,
170
+ registration,
171
+ github,
172
+ authentication_attempts,
173
+ child_calls,
174
+ )
175
+
176
+ assert authentication_attempts == []
177
+ offline_state = runner.run_once()[0]
178
+ persisted_offline_state = json.loads(
179
+ registration.state_path.read_text(encoding="utf-8")
180
+ )
181
+ passed_state = runner.run_once()[0]
182
+
183
+ assert offline_state.status == "offline"
184
+ assert persisted_offline_state["status"] == "offline"
185
+ assert passed_state.status == "passed"
186
+ assert authentication_attempts == ["attempt", "attempt", "attempt"]
187
+ assert child_calls == ["child"]
188
+
189
+
190
+ def test_runner_marks_missing_selected_manifest_incomplete(tmp_path: Path) -> None:
191
+ checkout_path, head_sha = support._build_checkout(tmp_path)
192
+ registration = support._build_registration(tmp_path, checkout_path)
193
+ github = support.FakeGitHub(support._build_candidate(head_sha))
194
+ publisher_calls: list[str] = []
195
+
196
+ runner = support.AutomaticAdvisoryRunner(
197
+ support._build_settings(registration),
198
+ github,
199
+ child_runner=lambda child_registration, _base_ref, _timeout_seconds: (
200
+ _run_report_only_child(child_registration)
201
+ ),
202
+ publisher=lambda *all_arguments: _record_unexpected_publication(
203
+ publisher_calls,
204
+ *all_arguments,
205
+ ),
206
+ clock=lambda: "2026-09-05T00:00:00+00:00",
207
+ )
208
+
209
+ state = runner.run_once()[0]
210
+
211
+ assert state.status == "error"
212
+ assert state.reason == "verification child did not produce a report"
213
+ assert publisher_calls == []
214
+
215
+
216
+ def test_fetch_base_returns_false_after_git_timeout(
217
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
218
+ ) -> None:
219
+ registration = support._build_registration(tmp_path, tmp_path)
220
+ candidate = support._build_candidate("head")
221
+ captured_options: dict[str, object] = {}
222
+
223
+ def timeout_run(*arguments: object, **options: object) -> object:
224
+ captured_options.update(options)
225
+ raise subprocess.TimeoutExpired("git", 30.0)
226
+
227
+ monkeypatch.setattr(git.subprocess, "run", timeout_run)
228
+
229
+ assert git._fetch_base(registration, candidate) is False
230
+ assert captured_options["timeout"] == 30.0
231
+ captured_environment = captured_options["env"]
232
+ assert isinstance(captured_environment, dict)
233
+ assert captured_environment.get("GIT_TERMINAL_PROMPT") == "0"
234
+
235
+
236
+ def test_fetch_base_updates_dedicated_and_remote_tracking_refs(
237
+ monkeypatch: pytest.MonkeyPatch,
238
+ tmp_path: Path,
239
+ ) -> None:
240
+ registration = support._build_registration(tmp_path, tmp_path)
241
+ candidate = support._build_candidate("head")
242
+ all_fetch_arguments: list[tuple[str, ...]] = []
243
+
244
+ def record_fetch(
245
+ fetch_arguments: tuple[str, ...],
246
+ **options: object,
247
+ ) -> subprocess.CompletedProcess[bytes]:
248
+ all_fetch_arguments.append(fetch_arguments)
249
+ return subprocess.CompletedProcess(fetch_arguments, 0, b"", b"")
250
+
251
+ monkeypatch.setattr(git.subprocess, "run", record_fetch)
252
+
253
+ assert git._fetch_base(registration, candidate) is True
254
+ assert all_fetch_arguments == [
255
+ (
256
+ "git",
257
+ "fetch",
258
+ "--force",
259
+ "--no-tags",
260
+ "origin",
261
+ "+refs/heads/main:refs/cde/advisory-base/2985",
262
+ "+refs/heads/main:refs/remotes/origin/main",
263
+ )
264
+ ]
265
+
266
+
267
+ def _build_descendant_parent_script(
268
+ descendant_identifier_path: Path,
269
+ parent_action: str,
270
+ ) -> str:
271
+ descendant_script = "import time; time.sleep(30)"
272
+ return (
273
+ "import subprocess,sys,time\n"
274
+ "from pathlib import Path\n"
275
+ f"child_process = subprocess.Popen((sys.executable, '-c', {descendant_script!r}))\n"
276
+ f"Path({str(descendant_identifier_path)!r}).write_text(str(child_process.pid), encoding='utf-8')\n"
277
+ f"{parent_action}\n"
278
+ )
279
+
280
+
281
+ def _build_process_runner_script(
282
+ checkout_path: Path,
283
+ descendant_identifier_path: Path,
284
+ parent_action: str,
285
+ ) -> str:
286
+ parent_script = _build_descendant_parent_script(
287
+ descendant_identifier_path,
288
+ parent_action,
289
+ )
290
+ return (
291
+ "import os,sys\n"
292
+ "from pathlib import Path\n"
293
+ "from types import SimpleNamespace\n"
294
+ "from automatic_advisory import execution\n"
295
+ f"registration = SimpleNamespace(checkout_path=Path({str(checkout_path)!r}))\n"
296
+ "scripts_directory = Path(execution.__file__).resolve().parents[1]\n"
297
+ "child_environment = execution._build_child_environment(registration, scripts_directory)\n"
298
+ "child_process = execution._start_child_process(\n"
299
+ f" (sys.executable, '-c', {parent_script!r}), registration, child_environment\n"
300
+ ")\n"
301
+ "if child_process is None:\n"
302
+ " raise SystemExit(2)\n"
303
+ f"child_outcome = execution._collect_child_outcome(child_process, {PRODUCTION_CHILD_TIMEOUT_SECONDS!r})\n"
304
+ "if not child_outcome.timed_out:\n"
305
+ " raise SystemExit(3)\n"
306
+ )
307
+
308
+
309
+ def _is_process_running(process_identifier: int) -> bool:
310
+ if sys.platform == "win32":
311
+ completed_process = subprocess.run(
312
+ ("tasklist", "/FI", f"PID eq {process_identifier}", "/FO", "CSV", "/NH"),
313
+ capture_output=True,
314
+ check=False,
315
+ text=True,
316
+ timeout=PROCESS_EXIT_WAIT_SECONDS,
317
+ )
318
+ return f'"{process_identifier}"' in completed_process.stdout
319
+ try:
320
+ os.kill(process_identifier, 0)
321
+ except ProcessLookupError:
322
+ return False
323
+ except PermissionError:
324
+ return True
325
+ return True
326
+
327
+
328
+ def _wait_for_process_exit(process_identifier: int) -> bool:
329
+ deadline = time.monotonic() + PROCESS_EXIT_WAIT_SECONDS
330
+ while time.monotonic() < deadline:
331
+ if not _is_process_running(process_identifier):
332
+ return True
333
+ time.sleep(PROCESS_POLL_SECONDS)
334
+ return not _is_process_running(process_identifier)
335
+
336
+
337
+ def _end_process_for_test(process_identifier: int) -> None:
338
+ if not _is_process_running(process_identifier):
339
+ return
340
+ if sys.platform == "win32":
341
+ subprocess.run(
342
+ ("taskkill", "/T", "/F", "/PID", str(process_identifier)),
343
+ capture_output=True,
344
+ check=False,
345
+ timeout=PROCESS_EXIT_WAIT_SECONDS,
346
+ )
347
+ return
348
+ os.kill(process_identifier, signal.SIGKILL)
349
+
350
+
351
+ @pytest.mark.parametrize(
352
+ "parent_action",
353
+ ("time.sleep(30)", "raise SystemExit(0)"),
354
+ ids=("timed-parent", "exited-parent"),
355
+ )
356
+ def test_production_launch_timeout_ends_descendant_and_keeps_runner_alive(
357
+ tmp_path: Path,
358
+ parent_action: str,
359
+ ) -> None:
360
+ descendant_identifier_path = tmp_path / "descendant.pid"
361
+ runner_script = _build_process_runner_script(
362
+ tmp_path,
363
+ descendant_identifier_path,
364
+ parent_action,
365
+ )
366
+ completed_runner = subprocess.run(
367
+ (sys.executable, "-c", runner_script),
368
+ cwd=support.SCRIPTS_DIRECTORY,
369
+ capture_output=True,
370
+ check=False,
371
+ text=True,
372
+ timeout=PROCESS_RUNNER_TIMEOUT_SECONDS,
373
+ )
374
+ assert descendant_identifier_path.is_file(), completed_runner.stderr
375
+ descendant_identifier = int(descendant_identifier_path.read_text(encoding="utf-8"))
376
+
377
+ try:
378
+ assert completed_runner.returncode == 0, completed_runner.stderr
379
+ assert _wait_for_process_exit(descendant_identifier)
380
+ finally:
381
+ _end_process_for_test(descendant_identifier)
382
+
383
+
384
+ def test_detached_start_resolves_relative_settings_before_spawn(
385
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
386
+ ) -> None:
387
+ all_launches: list[tuple[tuple[str, ...], dict[str, object]]] = []
388
+ monkeypatch.chdir(tmp_path)
389
+ monkeypatch.setattr(
390
+ cli.subprocess,
391
+ "Popen",
392
+ lambda arguments, **options: all_launches.append((tuple(arguments), options)),
393
+ )
394
+
395
+ assert cli.start_polling(Path("settings.json")) == 0
396
+
397
+ launch_arguments = all_launches[0][0]
398
+ assert launch_arguments[launch_arguments.index("--settings") + 1] == str(
399
+ tmp_path / "settings.json"
400
+ )
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ TEST_DIRECTORY = Path(__file__).resolve().parent
10
+ if str(TEST_DIRECTORY) not in sys.path:
11
+ sys.path.insert(0, str(TEST_DIRECTORY))
12
+
13
+ import test_automatic_advisory as support
14
+ from automatic_advisory import configuration as advisory_configuration
15
+
16
+
17
+ @pytest.mark.parametrize("field_name", ["poll_seconds", "child_timeout_seconds"])
18
+ @pytest.mark.parametrize("invalid_seconds", [float("nan"), float("inf"), float("-inf")])
19
+ def test_configuration_rejects_nonfinite_timing(
20
+ tmp_path: Path, field_name: str, invalid_seconds: float
21
+ ) -> None:
22
+ settings_path = tmp_path / "settings.json"
23
+ all_settings = support._valid_advisory_settings_fields(tmp_path)
24
+ settings_path.write_text(json.dumps(all_settings), encoding="utf-8")
25
+ valid_settings = advisory_configuration.load_advisory_settings(settings_path)
26
+ assert valid_settings.poll_seconds == 0.25
27
+ assert valid_settings.child_timeout_seconds == 45.5
28
+ all_settings[field_name] = invalid_seconds
29
+ settings_path.write_text(json.dumps(all_settings), encoding="utf-8")
30
+
31
+ with pytest.raises(
32
+ advisory_configuration.AdvisoryConfigurationError,
33
+ match=f"{field_name} must be positive",
34
+ ):
35
+ advisory_configuration.load_advisory_settings(settings_path)
36
+
37
+
38
+ def test_poll_error_log_sits_beside_the_registration_state_file(tmp_path: Path) -> None:
39
+ checkout_path = tmp_path / "checkout"
40
+ checkout_path.mkdir()
41
+ registration = support._build_registration(tmp_path, checkout_path)
42
+ settings = support._build_settings(registration)
43
+
44
+ assert (
45
+ settings.poll_error_log_path
46
+ == registration.state_path.parent / "poll-errors.log"
47
+ )
48
+ assert settings.poll_error_log_path.parent == settings.poll_lock_root.parent
@@ -0,0 +1,163 @@
1
+ """The advisory poller and every process it starts stay off the screen on Windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ TESTS_DIRECTORY = Path(__file__).resolve().parent
14
+ if str(TESTS_DIRECTORY) not in sys.path:
15
+ sys.path.insert(0, str(TESTS_DIRECTORY))
16
+
17
+ import test_automatic_advisory as support
18
+ from automatic_advisory import checkout, cli, execution, git, window_flags
19
+
20
+ WINDOWS_ONLY = pytest.mark.skipif(
21
+ sys.platform != "win32", reason="Windows console flags"
22
+ )
23
+ PROBE_WAIT_SECONDS = 10.0
24
+ PROBE_POLL_SECONDS = 0.1
25
+
26
+ CHILD_REPORT_SCRIPT = (
27
+ "import ctypes, json, sys;"
28
+ "handle = ctypes.windll.kernel32.GetConsoleWindow();"
29
+ "visible = bool(ctypes.windll.user32.IsWindowVisible(handle)) if handle else False;"
30
+ "sys.stdout.write(json.dumps({'visible': visible}))"
31
+ )
32
+
33
+ PARENT_PROBE_SCRIPT = """
34
+ import json, subprocess, sys
35
+ from pathlib import Path
36
+ completed = subprocess.run(
37
+ [sys.executable, "-c", {child_script!r}], capture_output=True, text=True, check=False
38
+ )
39
+ Path(sys.argv[1]).write_text(completed.stdout, encoding="utf-8")
40
+ """
41
+
42
+
43
+ def test_flags_are_zero_off_windows(monkeypatch: pytest.MonkeyPatch) -> None:
44
+ monkeypatch.setattr(window_flags.sys, "platform", "linux")
45
+ assert window_flags.hidden_window_creation_flags() == 0
46
+ assert window_flags.detached_poller_creation_flags() == 0
47
+
48
+
49
+ @WINDOWS_ONLY
50
+ def test_hidden_window_creation_flags_is_create_no_window() -> None:
51
+ assert window_flags.hidden_window_creation_flags() == subprocess.CREATE_NO_WINDOW
52
+
53
+
54
+ @WINDOWS_ONLY
55
+ def test_poller_flags_keep_a_hidden_console_instead_of_none() -> None:
56
+ poller_flags = window_flags.detached_poller_creation_flags()
57
+ assert poller_flags & subprocess.DETACHED_PROCESS == 0
58
+ assert poller_flags & subprocess.CREATE_NO_WINDOW
59
+ assert poller_flags & subprocess.CREATE_NEW_PROCESS_GROUP
60
+
61
+
62
+ @WINDOWS_ONLY
63
+ def test_poller_grandchild_gets_no_visible_console(tmp_path: Path) -> None:
64
+ parent_script_path = tmp_path / "parent_probe.py"
65
+ parent_script_path.write_text(
66
+ PARENT_PROBE_SCRIPT.format(child_script=CHILD_REPORT_SCRIPT), encoding="utf-8"
67
+ )
68
+ report_path = tmp_path / "grandchild.json"
69
+ subprocess.Popen(
70
+ [sys.executable, str(parent_script_path), str(report_path)],
71
+ stdin=subprocess.DEVNULL,
72
+ stdout=subprocess.DEVNULL,
73
+ stderr=subprocess.DEVNULL,
74
+ creationflags=window_flags.detached_poller_creation_flags(),
75
+ )
76
+ assert _read_probe_report(report_path) == {"visible": False}
77
+
78
+
79
+ def _read_probe_report(report_path: Path) -> dict[str, bool]:
80
+ deadline = time.monotonic() + PROBE_WAIT_SECONDS
81
+ while time.monotonic() < deadline:
82
+ if report_path.exists():
83
+ return json.loads(report_path.read_text(encoding="utf-8"))
84
+ time.sleep(PROBE_POLL_SECONDS)
85
+ raise AssertionError("the probe parent never wrote its report")
86
+
87
+
88
+ def test_git_fetch_hides_its_window(
89
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
90
+ ) -> None:
91
+ registration = support._build_registration(tmp_path, tmp_path)
92
+ candidate = support._build_candidate("head")
93
+ captured_options: dict[str, object] = {}
94
+
95
+ def record_run(
96
+ *arguments: object, **options: object
97
+ ) -> subprocess.CompletedProcess[bytes]:
98
+ captured_options.update(options)
99
+ return subprocess.CompletedProcess(arguments, 0, b"", b"")
100
+
101
+ monkeypatch.setattr(git.subprocess, "run", record_run)
102
+
103
+ assert git._fetch_base(registration, candidate) is True
104
+ assert (
105
+ captured_options["creationflags"] == window_flags.hidden_window_creation_flags()
106
+ )
107
+
108
+
109
+ def test_checkout_inspection_hides_its_window(
110
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
111
+ ) -> None:
112
+ all_captured_flags: list[object] = []
113
+
114
+ def record_run(
115
+ *arguments: object, **options: object
116
+ ) -> subprocess.CompletedProcess[str]:
117
+ all_captured_flags.append(options["creationflags"])
118
+ return subprocess.CompletedProcess(arguments, 0, "abc123\n", "")
119
+
120
+ monkeypatch.setattr(checkout.subprocess, "run", record_run)
121
+
122
+ local_checkout = checkout.read_local_checkout(tmp_path)
123
+
124
+ assert local_checkout.head_sha == "abc123"
125
+ assert all_captured_flags == [window_flags.hidden_window_creation_flags()] * 2
126
+
127
+
128
+ def test_windows_process_host_spawn_hides_its_window(
129
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
130
+ ) -> None:
131
+ registration = support._build_registration(tmp_path, tmp_path)
132
+ captured_options: dict[str, object] = {}
133
+
134
+ def record_popen(*arguments: object, **options: object) -> object:
135
+ captured_options.update(options)
136
+ return object()
137
+
138
+ monkeypatch.setattr(execution.subprocess, "Popen", record_popen)
139
+
140
+ execution._spawn_windows_process_host(("python", "-c", "pass"), registration, {})
141
+
142
+ expected_flags = window_flags.hidden_window_creation_flags() | getattr(
143
+ subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0
144
+ )
145
+ assert captured_options["creationflags"] == expected_flags
146
+
147
+
148
+ def test_start_polling_launches_with_the_poller_flags(
149
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
150
+ ) -> None:
151
+ captured_options: dict[str, object] = {}
152
+
153
+ def record_popen(*arguments: object, **options: object) -> object:
154
+ captured_options.update(options)
155
+ return object()
156
+
157
+ monkeypatch.setattr(cli.subprocess, "Popen", record_popen)
158
+
159
+ assert cli.start_polling(tmp_path / "settings.json") == 0
160
+ assert (
161
+ captured_options["creationflags"]
162
+ == window_flags.detached_poller_creation_flags()
163
+ )