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,396 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
9
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
11
+
12
+ from local_report_publisher import PublicationOutcome, publish_local_report
13
+ from local_verification.config import BASE_PLACEHOLDER
14
+ from local_verification.manifest import load_manifest
15
+ from local_verification.runner import run_verification
16
+ from pr_verification.github import GitHubApi, HttpReply
17
+ from pr_verification.model import RepositorySettings, StatusState
18
+
19
+
20
+ class RecordingRequester:
21
+ def __init__(self, all_replies: list[HttpReply]) -> None:
22
+ self.all_replies = all_replies
23
+ self.all_requests: list[tuple[str, str, dict[str, str], bytes | None]] = []
24
+
25
+ def __call__(
26
+ self,
27
+ method: str,
28
+ url: str,
29
+ headers: dict[str, str],
30
+ body: bytes | None,
31
+ ) -> HttpReply:
32
+ self.all_requests.append((method, url, headers, body))
33
+ return self.all_replies.pop(0)
34
+
35
+
36
+ def _assert_request_methods(
37
+ requester: RecordingRequester, expected_methods: str
38
+ ) -> None:
39
+ request_methods = " ".join(
40
+ each_request[0] for each_request in requester.all_requests
41
+ )
42
+ assert request_methods == expected_methods
43
+
44
+
45
+ def _run_git(repository_path: Path, *all_arguments: str) -> str:
46
+ completed_process = subprocess.run(
47
+ ["git", "-C", str(repository_path), *all_arguments],
48
+ capture_output=True,
49
+ check=False,
50
+ text=True,
51
+ )
52
+ assert completed_process.returncode == 0, completed_process.stderr
53
+ return completed_process.stdout.strip()
54
+
55
+
56
+ def _create_git_repository(tmp_path: Path) -> tuple[Path, str]:
57
+ repository_path = tmp_path / "repository"
58
+ repository_path.mkdir()
59
+ _run_git(repository_path, "init", "--quiet")
60
+ _run_git(repository_path, "config", "user.email", "tests@example.com")
61
+ _run_git(repository_path, "config", "user.name", "Local Verification Tests")
62
+ (repository_path / "tracked.txt").write_text("stable\n", encoding="utf-8")
63
+ _run_git(repository_path, "add", "tracked.txt")
64
+ _run_git(repository_path, "commit", "--quiet", "-m", "initial")
65
+ return repository_path, _run_git(repository_path, "rev-parse", "HEAD")
66
+
67
+
68
+ def _repository() -> RepositorySettings:
69
+ return RepositorySettings("owner/repository", "https://github.test/repository.git")
70
+
71
+
72
+ def _pull_payload(
73
+ head_sha: str, base_sha: str, merge_sha: str | None = "merge"
74
+ ) -> bytes:
75
+ return json.dumps(
76
+ {
77
+ "number": 7,
78
+ "draft": False,
79
+ "merge_commit_sha": merge_sha,
80
+ "base": {"ref": "main", "sha": base_sha},
81
+ "head": {"sha": head_sha},
82
+ }
83
+ ).encode("utf-8")
84
+
85
+
86
+ def _candidate_replies(
87
+ head_sha: str, base_sha: str, merge_sha: str | None = "merge"
88
+ ) -> list[HttpReply]:
89
+ return [
90
+ HttpReply(200, {}, _pull_payload(head_sha, base_sha, merge_sha)),
91
+ HttpReply(
92
+ 200,
93
+ {},
94
+ json.dumps({"object": {"sha": base_sha}}).encode("utf-8"),
95
+ ),
96
+ ]
97
+
98
+
99
+ def _advisory_replies(
100
+ head_sha: str, base_sha: str, merge_sha: str | None = "merge"
101
+ ) -> list[HttpReply]:
102
+ return [
103
+ *_candidate_replies(head_sha, base_sha, merge_sha),
104
+ HttpReply(200, {}, b'[{"name":"local-checks:passed"}]'),
105
+ HttpReply(204, {}, b""),
106
+ HttpReply(201, {}, b"{}"),
107
+ ]
108
+
109
+
110
+ def _write_manifest(
111
+ manifest_path: Path,
112
+ check_arguments: list[str],
113
+ minimum_tests: int | None = None,
114
+ timeout_seconds: float = 10.0,
115
+ ) -> None:
116
+ manifest_path.write_text(
117
+ json.dumps(
118
+ {
119
+ "version": 1,
120
+ "checks": [
121
+ {
122
+ "id": "selected",
123
+ "argv": check_arguments,
124
+ "cwd": ".",
125
+ "timeout_seconds": timeout_seconds,
126
+ **(
127
+ {"minimum_tests": minimum_tests}
128
+ if minimum_tests is not None
129
+ else {}
130
+ ),
131
+ }
132
+ ],
133
+ }
134
+ ),
135
+ encoding="utf-8",
136
+ )
137
+
138
+
139
+ def _run_report(
140
+ tmp_path: Path,
141
+ check_arguments: list[str],
142
+ minimum_tests: int | None = None,
143
+ timeout_seconds: float = 10.0,
144
+ base_revision: str | None = None,
145
+ ) -> tuple[Path, Path, str, str]:
146
+ repository_path, base_sha = _create_git_repository(tmp_path)
147
+ manifest_path = tmp_path / "manifest.json"
148
+ _write_manifest(manifest_path, check_arguments, minimum_tests, timeout_seconds)
149
+ manifest = load_manifest(manifest_path)
150
+ report_path = tmp_path / "report.json"
151
+ report = run_verification(
152
+ manifest, repository_path, base_revision or base_sha, report_path
153
+ )
154
+ assert report.head_revision is not None
155
+ return repository_path, manifest_path, base_sha, report.head_revision
156
+
157
+
158
+ def _base_check_arguments() -> list[str]:
159
+ return [
160
+ sys.executable,
161
+ "-c",
162
+ "import sys; assert len(sys.argv[1]) == 40",
163
+ BASE_PLACEHOLDER,
164
+ ]
165
+
166
+
167
+ def _assert_recorded_base(report_path: Path, base_sha: str) -> None:
168
+ report_fields = json.loads(report_path.read_text(encoding="utf-8"))
169
+ assert report_fields["checks"][0]["argv"][-1] == base_sha
170
+
171
+
172
+ def _publish_report(
173
+ repository_path: Path,
174
+ manifest_path: Path,
175
+ report_path: Path,
176
+ requester: RecordingRequester,
177
+ ) -> PublicationOutcome:
178
+ return publish_local_report(
179
+ GitHubApi("https://api.github.test", "token", requester),
180
+ _repository(),
181
+ 7,
182
+ repository_path,
183
+ manifest_path,
184
+ report_path,
185
+ )
186
+
187
+
188
+ def _missing_key_arguments(tmp_path: Path) -> list[str]:
189
+ return [
190
+ "--api-url",
191
+ "https://api.github.test",
192
+ "--app-id",
193
+ "42",
194
+ "--installation-id",
195
+ "84",
196
+ "--private-key-path",
197
+ str(tmp_path / "missing.pem"),
198
+ "--repository",
199
+ "owner/repository",
200
+ "--pull-number",
201
+ "7",
202
+ "--local-repo",
203
+ str(tmp_path),
204
+ "--manifest",
205
+ str(tmp_path / "manifest.json"),
206
+ "--report",
207
+ str(tmp_path / "report.json"),
208
+ ]
209
+
210
+
211
+ def test_publishes_success_for_matching_clean_report(tmp_path: Path) -> None:
212
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
213
+ tmp_path, _base_check_arguments(), base_revision="HEAD"
214
+ )
215
+ _assert_recorded_base(tmp_path / "report.json", base_sha)
216
+ requester = RecordingRequester(
217
+ [
218
+ *_candidate_replies(head_sha, base_sha),
219
+ *_candidate_replies(head_sha, base_sha),
220
+ HttpReply(201, {}, b"{}"),
221
+ HttpReply(200, {}, b"[]"),
222
+ *_candidate_replies(head_sha, base_sha),
223
+ ]
224
+ )
225
+ outcome = _publish_report(
226
+ repository_path, manifest_path, tmp_path / "report.json", requester
227
+ )
228
+
229
+ assert outcome.status == StatusState.SUCCESS
230
+ assert outcome.publishable is True
231
+ assert "Selected local checks passed (1 checks)" == outcome.description
232
+ _assert_request_methods(requester, "GET GET GET GET POST POST GET GET")
233
+ assert requester.all_requests[1][1].endswith("/git/ref/heads/main")
234
+ assert requester.all_requests[4][1].endswith(f"/statuses/{head_sha}")
235
+
236
+
237
+ def test_failed_report_removes_pass_label_and_posts_failure(tmp_path: Path) -> None:
238
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
239
+ tmp_path, [sys.executable, "-c", "raise SystemExit(4)"]
240
+ )
241
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
242
+ outcome = _publish_report(
243
+ repository_path, manifest_path, tmp_path / "report.json", requester
244
+ )
245
+
246
+ assert outcome.status == StatusState.FAILURE
247
+ assert outcome.publishable is False
248
+ _assert_request_methods(requester, "GET GET GET DELETE POST")
249
+ assert requester.all_requests[2][1].endswith(
250
+ "/issues/7/labels?per_page=100&page=1"
251
+ )
252
+ assert requester.all_requests[3][1].endswith(
253
+ "/issues/7/labels/local-checks%3Apassed"
254
+ )
255
+
256
+
257
+ def test_failed_execution_with_collection_floor_posts_failure(tmp_path: Path) -> None:
258
+ failing_test_path = tmp_path / "test_failure.py"
259
+ failing_test_path.write_text(
260
+ "def test_selected():\n assert False\n",
261
+ encoding="utf-8",
262
+ )
263
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
264
+ tmp_path,
265
+ [sys.executable, "-m", "pytest", str(failing_test_path)],
266
+ 1,
267
+ )
268
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
269
+ outcome = _publish_report(
270
+ repository_path, manifest_path, tmp_path / "report.json", requester
271
+ )
272
+
273
+ assert outcome.status == StatusState.FAILURE
274
+ assert outcome.publishable is False
275
+
276
+
277
+ def test_incomplete_report_posts_error_without_pass_label(tmp_path: Path) -> None:
278
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
279
+ tmp_path, ["missing-command"]
280
+ )
281
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
282
+ outcome = _publish_report(
283
+ repository_path, manifest_path, tmp_path / "report.json", requester
284
+ )
285
+
286
+ assert outcome.status == StatusState.ERROR
287
+ assert outcome.publishable is False
288
+ assert [each_request[0] for each_request in requester.all_requests[-3:]] == [
289
+ "GET",
290
+ "DELETE",
291
+ "POST",
292
+ ]
293
+
294
+
295
+ def test_timeout_after_collection_posts_error(tmp_path: Path) -> None:
296
+ sleeping_test_path = tmp_path / "test_timeout.py"
297
+ sleeping_test_path.write_text(
298
+ "import time\ndef test_slow():\n time.sleep(1)\n",
299
+ encoding="utf-8",
300
+ )
301
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
302
+ tmp_path,
303
+ [sys.executable, "-m", "pytest", str(sleeping_test_path)],
304
+ 1,
305
+ 0.1,
306
+ )
307
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
308
+ outcome = _publish_report(
309
+ repository_path, manifest_path, tmp_path / "report.json", requester
310
+ )
311
+
312
+ assert outcome.status == StatusState.ERROR
313
+ assert outcome.publishable is False
314
+
315
+
316
+ def test_collection_failure_posts_error(tmp_path: Path) -> None:
317
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
318
+ tmp_path,
319
+ [sys.executable, "-m", "pytest", str(tmp_path / "missing_tests.py")],
320
+ 1,
321
+ )
322
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
323
+ outcome = _publish_report(
324
+ repository_path, manifest_path, tmp_path / "report.json", requester
325
+ )
326
+
327
+ assert outcome.status == StatusState.ERROR
328
+ assert outcome.publishable is False
329
+
330
+
331
+ def test_advisory_reads_pull_request_without_merge_commit(tmp_path: Path) -> None:
332
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
333
+ tmp_path, [sys.executable, "-c", "raise SystemExit(4)"]
334
+ )
335
+ requester = RecordingRequester(
336
+ _advisory_replies(head_sha, base_sha, merge_sha=None)
337
+ )
338
+ outcome = _publish_report(
339
+ repository_path, manifest_path, tmp_path / "report.json", requester
340
+ )
341
+
342
+ assert outcome.status == StatusState.FAILURE
343
+ assert outcome.publishable is False
344
+
345
+
346
+ def test_wrong_manifest_removes_pass_label_and_posts_error(tmp_path: Path) -> None:
347
+ repository_path, _manifest_path, base_sha, head_sha = _run_report(
348
+ tmp_path, [sys.executable, "-c", "pass"]
349
+ )
350
+ wrong_manifest_path = tmp_path / "wrong-manifest.json"
351
+ _write_manifest(wrong_manifest_path, [sys.executable, "-c", "pass"])
352
+ wrong_manifest_text = wrong_manifest_path.read_text(encoding="utf-8")
353
+ wrong_manifest_path.write_text(
354
+ wrong_manifest_text.replace('"selected"', '"other"'),
355
+ encoding="utf-8",
356
+ )
357
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
358
+ outcome = _publish_report(
359
+ repository_path, wrong_manifest_path, tmp_path / "report.json", requester
360
+ )
361
+
362
+ assert outcome.status == StatusState.ERROR
363
+ assert outcome.publishable is False
364
+ _assert_request_methods(requester, "GET GET GET DELETE POST")
365
+
366
+
367
+ def test_success_drift_after_label_removes_label_and_posts_error(
368
+ tmp_path: Path,
369
+ ) -> None:
370
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
371
+ tmp_path, [sys.executable, "-c", "pass"]
372
+ )
373
+ changed_head = "changed-head"
374
+ requester = RecordingRequester(
375
+ [
376
+ *_candidate_replies(head_sha, base_sha),
377
+ *_candidate_replies(head_sha, base_sha),
378
+ HttpReply(201, {}, b"{}"),
379
+ HttpReply(200, {}, b"[]"),
380
+ *_candidate_replies(changed_head, base_sha),
381
+ HttpReply(200, {}, b'[{"name":"local-checks:passed"}]'),
382
+ HttpReply(204, {}, b""),
383
+ HttpReply(201, {}, b"{}"),
384
+ ]
385
+ )
386
+ outcome = _publish_report(
387
+ repository_path, manifest_path, tmp_path / "report.json", requester
388
+ )
389
+
390
+ assert outcome.status == StatusState.ERROR
391
+ assert outcome.publishable is False
392
+ assert [each_request[0] for each_request in requester.all_requests[-3:]] == [
393
+ "GET",
394
+ "DELETE",
395
+ "POST",
396
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
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
+ TESTS_DIRECTORY = Path(__file__).resolve().parent
13
+ if str(TESTS_DIRECTORY) not in sys.path:
14
+ sys.path.insert(0, str(TESTS_DIRECTORY))
15
+
16
+ from local_report_publisher import main
17
+ from pr_verification.model import StatusState
18
+ from test_local_report_publisher import (
19
+ RecordingRequester,
20
+ _advisory_replies,
21
+ _assert_request_methods,
22
+ _missing_key_arguments,
23
+ _publish_report,
24
+ _run_report,
25
+ )
26
+
27
+
28
+ def test_tampered_command_removes_pass_label_and_posts_error(tmp_path: Path) -> None:
29
+ repository_path, manifest_path, base_sha, head_sha = _run_report(
30
+ tmp_path, [sys.executable, "-c", "pass"]
31
+ )
32
+ report_path = tmp_path / "report.json"
33
+ report_fields = json.loads(report_path.read_text(encoding="utf-8"))
34
+ report_fields["checks"][0]["argv"] = [sys.executable, "-c", "raise SystemExit(9)"]
35
+ report_path.write_text(json.dumps(report_fields), encoding="utf-8")
36
+ requester = RecordingRequester(_advisory_replies(head_sha, base_sha))
37
+
38
+ outcome = _publish_report(repository_path, manifest_path, report_path, requester)
39
+
40
+ assert outcome.status == StatusState.ERROR
41
+ assert outcome.publishable is False
42
+ _assert_request_methods(requester, "GET GET GET DELETE POST")
43
+
44
+
45
+ def test_cli_reports_missing_key_without_network_access(tmp_path: Path) -> None:
46
+ standard_output = io.StringIO()
47
+ standard_error = io.StringIO()
48
+
49
+ exit_code = main(
50
+ _missing_key_arguments(tmp_path),
51
+ stdout=standard_output,
52
+ stderr=standard_error,
53
+ )
54
+
55
+ assert exit_code == 3
56
+ assert standard_output.getvalue() == ""
57
+ assert "missing.pem" in standard_error.getvalue()
58
+
59
+
60
+ def test_cli_rejects_malformed_repository_without_network_access(
61
+ tmp_path: Path,
62
+ ) -> None:
63
+ all_arguments = _missing_key_arguments(tmp_path)
64
+ repository_index = all_arguments.index("--repository") + 1
65
+ all_arguments[repository_index] = "owner"
66
+ standard_error = io.StringIO()
67
+
68
+ exit_code = main(all_arguments, stderr=standard_error)
69
+
70
+ assert exit_code == 3
71
+ assert "owner/name" in standard_error.getvalue()
72
+ assert "Traceback" not in standard_error.getvalue()
73
+
74
+
75
+ def test_publisher_script_displays_help_without_authentication() -> None:
76
+ publisher_path = Path(__file__).resolve().parents[1] / "local_report_publisher.py"
77
+ completed_process = subprocess.run(
78
+ [sys.executable, str(publisher_path), "--help"],
79
+ capture_output=True,
80
+ text=True,
81
+ check=False,
82
+ )
83
+
84
+ assert completed_process.returncode == 0
85
+ assert "usage:" in completed_process.stdout