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,348 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
11
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
13
+
14
+ from local_verification import check_runner
15
+ from local_verification.config.constants import (
16
+ COLLECTION_ERROR_KIND,
17
+ FAILED_STATUS,
18
+ NONZERO_EXIT_ERROR_KIND,
19
+ )
20
+ from local_verification.manifest import (
21
+ ManifestRunFatal,
22
+ compute_manifest_digest,
23
+ load_manifest,
24
+ )
25
+ from local_verification.model import (
26
+ CheckSpec,
27
+ CommandCapture,
28
+ ExclusionSpec,
29
+ VerificationManifest,
30
+ VerificationReport,
31
+ )
32
+ from local_verification.runner import run_verification
33
+
34
+
35
+ def _manifest(*checks: CheckSpec) -> VerificationManifest:
36
+ return VerificationManifest(1, tuple(checks), ())
37
+
38
+
39
+ def _python_check(check_id: str, script_text: str, cwd: str = ".") -> CheckSpec:
40
+ return CheckSpec(check_id, (sys.executable, "-c", script_text), cwd, 10.0)
41
+
42
+
43
+ def _run_git(repository_path: Path, *all_arguments: str) -> str:
44
+ completed_process = subprocess.run(
45
+ ["git", "-C", str(repository_path), *all_arguments],
46
+ capture_output=True,
47
+ check=False,
48
+ text=True,
49
+ )
50
+ assert completed_process.returncode == 0, completed_process.stderr
51
+ return completed_process.stdout.strip()
52
+
53
+
54
+ def _create_git_repository(tmp_path: Path) -> tuple[Path, str]:
55
+ repository_path = tmp_path / "repository"
56
+ repository_path.mkdir()
57
+ _run_git(repository_path, "init", "--quiet")
58
+ _run_git(repository_path, "config", "user.email", "tests@example.com")
59
+ _run_git(repository_path, "config", "user.name", "Local Verification Tests")
60
+ (repository_path / "tracked.txt").write_text("stable\n", encoding="utf-8")
61
+ _run_git(repository_path, "add", "tracked.txt")
62
+ _run_git(repository_path, "commit", "--quiet", "-m", "initial")
63
+ return repository_path, _run_git(repository_path, "rev-parse", "HEAD")
64
+
65
+
66
+ def _assert_check_outputs(report: VerificationReport, output_path: Path) -> None:
67
+ assert report.aggregate_status == "failed"
68
+ assert [each_record.status for each_record in report.checks] == [
69
+ "passed",
70
+ "failed",
71
+ "incomplete",
72
+ "incomplete",
73
+ ]
74
+ assert report.checks[1].exit_code == 7
75
+ assert report.checks[2].error_kind == "missing_tool"
76
+ assert report.checks[3].error_kind == "timeout"
77
+ assert output_path.exists()
78
+ for each_record in report.checks:
79
+ assert each_record.stdout_log.exists()
80
+ assert each_record.stderr_log.exists()
81
+ run_log_path = output_path.with_suffix(".logs") / "run.log"
82
+ assert "aggregate: failed" in run_log_path.read_text(encoding="utf-8")
83
+
84
+
85
+ def test_load_manifest_requires_unique_check_ids(tmp_path: Path) -> None:
86
+ manifest_path = tmp_path / "manifest.json"
87
+ manifest_path.write_text(
88
+ json.dumps(
89
+ {
90
+ "version": 1,
91
+ "checks": [
92
+ {
93
+ "id": "same",
94
+ "argv": ["python"],
95
+ "cwd": ".",
96
+ "timeout_seconds": 1,
97
+ },
98
+ {
99
+ "id": "same",
100
+ "argv": ["python"],
101
+ "cwd": ".",
102
+ "timeout_seconds": 1,
103
+ },
104
+ ],
105
+ }
106
+ ),
107
+ encoding="utf-8",
108
+ )
109
+
110
+ with pytest.raises(ManifestRunFatal, match="unique"):
111
+ load_manifest(manifest_path)
112
+
113
+
114
+ def test_load_manifest_preserves_exclusions_and_collection_floor(
115
+ tmp_path: Path,
116
+ ) -> None:
117
+ manifest_path = tmp_path / "manifest.json"
118
+ manifest_path.write_text(
119
+ json.dumps(
120
+ {
121
+ "version": 1,
122
+ "checks": [
123
+ {
124
+ "id": "tests",
125
+ "argv": ["python", "-m", "pytest"],
126
+ "cwd": "packages/app",
127
+ "timeout_seconds": 2,
128
+ "minimum_tests": 3,
129
+ }
130
+ ],
131
+ "exclusions": [{"selector": "manual", "reason": "requires a human"}],
132
+ }
133
+ ),
134
+ encoding="utf-8",
135
+ )
136
+
137
+ loaded_manifest = load_manifest(manifest_path)
138
+
139
+ assert loaded_manifest.checks[0].minimum_tests == 3
140
+ assert loaded_manifest.checks[0].cwd == "packages/app"
141
+ assert loaded_manifest.exclusions == (ExclusionSpec("manual", "requires a human"),)
142
+
143
+
144
+ def test_run_verification_executes_pass_fail_missing_and_timeout_checks(
145
+ tmp_path: Path,
146
+ ) -> None:
147
+ all_checks = (
148
+ _python_check("passes", ""),
149
+ _python_check("fails", "raise SystemExit(7)"),
150
+ CheckSpec("missing", ("command-that-does-not-exist",), ".", 10.0),
151
+ _python_check("times-out", "import time; time.sleep(2)"),
152
+ )
153
+ timed_check = CheckSpec("times-out", all_checks[-1].command_arguments, ".", 0.1)
154
+ output_path = tmp_path / "reports" / "verification.json"
155
+
156
+ report = run_verification(
157
+ _manifest(all_checks[0], all_checks[1], all_checks[2], timed_check),
158
+ tmp_path,
159
+ "base-sha",
160
+ output_path,
161
+ )
162
+
163
+ _assert_check_outputs(report, output_path)
164
+
165
+
166
+ def test_run_verification_keeps_argv_spaces_and_runs_outside_caller_directory(
167
+ tmp_path: Path,
168
+ ) -> None:
169
+ repository_path = tmp_path / "repository with spaces"
170
+ repository_path.mkdir()
171
+ script_path = repository_path / "folder with spaces" / "check.py"
172
+ script_path.parent.mkdir()
173
+ script_path.write_text("raise SystemExit(0)\n", encoding="utf-8")
174
+ check = CheckSpec("spaces", (sys.executable, str(script_path)), ".", 10.0)
175
+
176
+ report = run_verification(
177
+ _manifest(check),
178
+ repository_path,
179
+ "base-sha",
180
+ tmp_path / "verification.json",
181
+ )
182
+
183
+ assert report.aggregate_status == "passed"
184
+ assert report.checks[0].command_arguments == (sys.executable, str(script_path))
185
+
186
+
187
+ def test_run_verification_enforces_minimum_tests_for_pytest(tmp_path: Path) -> None:
188
+ empty_tests = tmp_path / "tests"
189
+ empty_tests.mkdir()
190
+ check = CheckSpec(
191
+ "pytest",
192
+ (sys.executable, "-m", "pytest", "tests"),
193
+ ".",
194
+ 30.0,
195
+ 1,
196
+ )
197
+
198
+ report = run_verification(
199
+ _manifest(check),
200
+ tmp_path,
201
+ "base-sha",
202
+ tmp_path / "verification.json",
203
+ )
204
+
205
+ assert report.aggregate_status == "failed"
206
+ assert report.checks[0].status == "failed"
207
+ assert report.checks[0].error_kind == "minimum_tests"
208
+ assert report.checks[0].collected_tests == 0
209
+
210
+
211
+ def test_run_verification_rejects_all_required_tests_skipped(tmp_path: Path) -> None:
212
+ skipped_test_path = tmp_path / "test_skipped.py"
213
+ skipped_test_path.write_text(
214
+ """import pytest
215
+
216
+ @pytest.mark.skip(reason="requires service")
217
+ def test_required_service():
218
+ pass
219
+ """,
220
+ encoding="utf-8",
221
+ )
222
+ check = CheckSpec(
223
+ "pytest",
224
+ (sys.executable, "-m", "pytest", "test_skipped.py"),
225
+ ".",
226
+ 30.0,
227
+ 1,
228
+ )
229
+
230
+ report = run_verification(
231
+ _manifest(check),
232
+ tmp_path,
233
+ "base-sha",
234
+ tmp_path / "verification.json",
235
+ )
236
+
237
+ assert report.aggregate_status == "incomplete"
238
+ assert report.checks[0].error_kind == "skipped_tests"
239
+ assert report.checks[0].collected_tests == 1
240
+ assert report.checks[0].exit_code == 0
241
+
242
+
243
+ def test_run_verification_records_publishable_clean_git_revision(
244
+ tmp_path: Path,
245
+ ) -> None:
246
+ repository_path, base_revision = _create_git_repository(tmp_path)
247
+ check = _python_check("passes", "")
248
+ manifest = _manifest(check)
249
+ output_path = tmp_path / "verification.json"
250
+
251
+ report = run_verification(
252
+ manifest,
253
+ repository_path,
254
+ base_revision,
255
+ output_path,
256
+ )
257
+
258
+ assert report.head_revision == base_revision
259
+ assert report.base_revision == base_revision
260
+ assert report.manifest_digest == compute_manifest_digest(manifest)
261
+ assert report.worktree_clean is True
262
+ assert report.inputs_unchanged is True
263
+ assert report.publishable is True
264
+ report_mapping = json.loads(output_path.read_text(encoding="utf-8"))
265
+ assert report_mapping["head"] == base_revision
266
+ assert report_mapping["base"] == base_revision
267
+ assert report_mapping["manifest_digest"] == report.manifest_digest
268
+ assert report_mapping["publishable"] is True
269
+
270
+
271
+ def test_run_verification_keeps_dirty_git_candidate_advisory(
272
+ tmp_path: Path,
273
+ ) -> None:
274
+ repository_path, base_revision = _create_git_repository(tmp_path)
275
+ (repository_path / "untracked.txt").write_text("local\n", encoding="utf-8")
276
+
277
+ report = run_verification(
278
+ _manifest(_python_check("passes", "")),
279
+ repository_path,
280
+ base_revision,
281
+ tmp_path / "verification.json",
282
+ )
283
+
284
+ assert report.aggregate_status == "passed"
285
+ assert report.worktree_clean is False
286
+ assert report.inputs_unchanged is True
287
+ assert report.publishable is False
288
+
289
+
290
+ def test_run_verification_invalidates_git_candidate_when_head_changes(
291
+ tmp_path: Path,
292
+ ) -> None:
293
+ repository_path, base_revision = _create_git_repository(tmp_path)
294
+ tracked_path = repository_path / "tracked.txt"
295
+ commit_script = (
296
+ "from pathlib import Path; import subprocess; "
297
+ f"Path({str(tracked_path)!r}).write_text('changed\\n'); "
298
+ f"subprocess.run(['git', '-C', {str(repository_path)!r}, 'add', 'tracked.txt'], check=True); "
299
+ f"subprocess.run(['git', '-C', {str(repository_path)!r}, 'commit', '--quiet', '-m', 'check'], check=True)"
300
+ )
301
+
302
+ report = run_verification(
303
+ _manifest(_python_check("changes-head", commit_script)),
304
+ repository_path,
305
+ base_revision,
306
+ tmp_path / "verification.json",
307
+ )
308
+
309
+ assert report.aggregate_status == "passed"
310
+ assert report.head_revision == base_revision
311
+ assert _run_git(repository_path, "rev-parse", "HEAD") != base_revision
312
+ assert report.inputs_unchanged is False
313
+ assert report.publishable is False
314
+
315
+
316
+ def test_run_verification_never_publishes_failed_or_incomplete_git_checks(
317
+ tmp_path: Path,
318
+ ) -> None:
319
+ repository_path, base_revision = _create_git_repository(tmp_path)
320
+ all_checks = (
321
+ _python_check("fails", "raise SystemExit(4)"),
322
+ CheckSpec("missing", ("missing-command",), ".", 10.0),
323
+ )
324
+
325
+ report = run_verification(
326
+ _manifest(*all_checks),
327
+ repository_path,
328
+ base_revision,
329
+ tmp_path / "verification.json",
330
+ )
331
+
332
+ assert report.aggregate_status == "failed"
333
+ assert [each_record.status for each_record in report.checks] == [
334
+ "failed",
335
+ "incomplete",
336
+ ]
337
+ assert report.publishable is False
338
+
339
+
340
+ def test_failed_command_keeps_failed_status_when_collection_also_fails() -> None:
341
+ check = CheckSpec("unit", ("pytest",), ".", 10.0, minimum_tests=1)
342
+ failed_collection = CommandCapture(1, "", "", COLLECTION_ERROR_KIND, "no collect")
343
+
344
+ status, error_kind, _ = check_runner._classify_collection(
345
+ check, (failed_collection, None), FAILED_STATUS, NONZERO_EXIT_ERROR_KIND, "x"
346
+ )
347
+
348
+ assert (status, error_kind) == (FAILED_STATUS, NONZERO_EXIT_ERROR_KIND)
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
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_verification.cli import main
13
+
14
+
15
+ def _write_failure_manifest(tmp_path: Path) -> Path:
16
+ manifest_path = tmp_path / "manifest.json"
17
+ manifest_path.write_text(
18
+ json.dumps(
19
+ {
20
+ "version": 1,
21
+ "checks": [
22
+ {
23
+ "id": "fails",
24
+ "argv": [sys.executable, "-c", "raise SystemExit(4)"],
25
+ "cwd": ".",
26
+ "timeout_seconds": 10,
27
+ }
28
+ ],
29
+ }
30
+ ),
31
+ encoding="utf-8",
32
+ )
33
+ return manifest_path
34
+
35
+
36
+ def _run_failure_cli(tmp_path: Path) -> tuple[int, str, str, Path]:
37
+ manifest_path = _write_failure_manifest(tmp_path)
38
+ output_path = tmp_path / "verification.json"
39
+ standard_output = io.StringIO()
40
+ standard_error = io.StringIO()
41
+ exit_code = main(
42
+ [
43
+ "--manifest",
44
+ str(manifest_path),
45
+ "--repo",
46
+ str(tmp_path),
47
+ "--base",
48
+ "base-sha",
49
+ "--output",
50
+ str(output_path),
51
+ ],
52
+ stdout=standard_output,
53
+ stderr=standard_error,
54
+ )
55
+ return exit_code, standard_output.getvalue(), standard_error.getvalue(), output_path
56
+
57
+
58
+ def test_cli_writes_json_and_returns_failure_code(tmp_path: Path) -> None:
59
+ exit_code, standard_output, standard_error, output_path = _run_failure_cli(tmp_path)
60
+
61
+ assert exit_code == 1
62
+ assert "check start: fails" in standard_output
63
+ assert "aggregate: failed" in standard_output
64
+ assert "revision: head=unresolved base=unresolved" in standard_output
65
+ assert (
66
+ "eligibility: clean=false unchanged=false publishable=false" in standard_output
67
+ )
68
+ assert f"report: {output_path}" in standard_output
69
+ assert "verification failed" in standard_error
70
+ assert (
71
+ json.loads(output_path.read_text(encoding="utf-8"))["aggregate"]["status"]
72
+ == "failed"
73
+ )
74
+
75
+
76
+ def test_cli_rejects_zero_checks_without_writing_success(tmp_path: Path) -> None:
77
+ manifest_path = tmp_path / "manifest.json"
78
+ manifest_path.write_text(json.dumps({"version": 1, "checks": []}), encoding="utf-8")
79
+ output_path = tmp_path / "verification.json"
80
+ output_path.write_text(json.dumps({"publishable": True}), encoding="utf-8")
81
+
82
+ exit_code = main(
83
+ [
84
+ "--manifest",
85
+ str(manifest_path),
86
+ "--repo",
87
+ str(tmp_path),
88
+ "--base",
89
+ "base-sha",
90
+ "--output",
91
+ str(output_path),
92
+ ],
93
+ stdout=io.StringIO(),
94
+ stderr=io.StringIO(),
95
+ )
96
+
97
+ assert exit_code == 2
98
+ assert not output_path.exists()
@@ -0,0 +1,163 @@
1
+ """Test pairing behavior for changed source documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ from policy_lint import adapters
8
+ from policy_lint.config.approved_test_pairs import (
9
+ APPROVED_TEST_PATHS_BY_PRODUCTION_PATH,
10
+ )
11
+ from policy_lint.model import (
12
+ ContentOrigin,
13
+ Diagnostic,
14
+ Document,
15
+ DocumentSet,
16
+ SelectionKind,
17
+ )
18
+
19
+ _MODULE_DOCSTRING_BEFORE = '''"""Before."""
20
+
21
+ def work() -> None:
22
+ """Old function documentation."""
23
+ return None
24
+ '''
25
+ _MODULE_DOCSTRING_AFTER = '''"""After."""
26
+
27
+ def work() -> None:
28
+ """Updated function documentation."""
29
+ return None
30
+ '''
31
+ _BODY_BEFORE = '''"""Before."""
32
+
33
+ def work() -> None:
34
+ return None
35
+ '''
36
+ _BODY_AFTER = '''"""After."""
37
+
38
+ def work() -> None:
39
+ return 1
40
+ '''
41
+
42
+
43
+ def _changed_document(
44
+ current_source: str, prior_source: str, suffix: str = ".py"
45
+ ) -> Document:
46
+ return Document(
47
+ PurePosixPath(f"src/feature{suffix}"),
48
+ current_source,
49
+ prior_source,
50
+ frozenset({1}),
51
+ ContentOrigin.REVISION_DIFF,
52
+ )
53
+
54
+
55
+ def _pairing_diagnostics(
56
+ temporary_path: Path, changed_document: Document
57
+ ) -> tuple[Diagnostic, ...]:
58
+ document_set = DocumentSet((changed_document,), SelectionKind.BASE, temporary_path)
59
+ return adapters.test_pairing_diagnostics(document_set)
60
+
61
+
62
+ def test_approved_pairs_name_files_that_exist() -> None:
63
+ repository_root = Path(__file__).resolve().parents[4]
64
+ for (
65
+ production_path,
66
+ all_test_paths,
67
+ ) in APPROVED_TEST_PATHS_BY_PRODUCTION_PATH.items():
68
+ assert (repository_root / production_path).is_file(), production_path
69
+ for each_test_path in all_test_paths:
70
+ assert (repository_root / each_test_path).is_file(), each_test_path
71
+
72
+
73
+ def test_pairing_ignores_python_docstring_only_changes(tmp_path: Path) -> None:
74
+ all_diagnostics = _pairing_diagnostics(
75
+ tmp_path,
76
+ _changed_document(_MODULE_DOCSTRING_AFTER, _MODULE_DOCSTRING_BEFORE),
77
+ )
78
+ assert all_diagnostics == ()
79
+
80
+
81
+ def test_pairing_requires_a_test_for_python_body_changes(tmp_path: Path) -> None:
82
+ all_diagnostics = _pairing_diagnostics(
83
+ tmp_path,
84
+ _changed_document(_BODY_AFTER, _BODY_BEFORE),
85
+ )
86
+ assert len(all_diagnostics) == 1
87
+
88
+
89
+ def test_pairing_rejects_python_syntax_errors_without_a_test(tmp_path: Path) -> None:
90
+ all_diagnostics = _pairing_diagnostics(
91
+ tmp_path,
92
+ _changed_document("def work(:\n return None\n", _BODY_BEFORE),
93
+ )
94
+ assert len(all_diagnostics) == 1
95
+
96
+
97
+ def test_pairing_keeps_non_python_changes_on_existing_matching_rules(
98
+ tmp_path: Path,
99
+ ) -> None:
100
+ all_diagnostics = _pairing_diagnostics(
101
+ tmp_path,
102
+ _changed_document(
103
+ "export const work = 1;\n", "export const work = 2;\n", ".ts"
104
+ ),
105
+ )
106
+ assert len(all_diagnostics) == 1
107
+
108
+
109
+ _APPROVED_PRODUCTION_PATH = PurePosixPath(
110
+ "packages/claude-dev-env/scripts/automatic_advisory/state.py"
111
+ )
112
+ _APPROVED_TEST_PATH = PurePosixPath(
113
+ "packages/claude-dev-env/scripts/tests/test_closed_pr_label.py"
114
+ )
115
+ _UNRELATED_PRODUCTION_PATH = PurePosixPath(
116
+ "packages/claude-dev-env/scripts/automatic_advisory/new_module.py"
117
+ )
118
+
119
+
120
+ def _body_change_at(path: PurePosixPath) -> Document:
121
+ return Document(
122
+ path, _BODY_AFTER, _BODY_BEFORE, frozenset({4}), ContentOrigin.REVISION_DIFF
123
+ )
124
+
125
+
126
+ def _diagnostic_paths(
127
+ temporary_path: Path, *all_documents: Document
128
+ ) -> tuple[PurePosixPath, ...]:
129
+ document_set = DocumentSet(all_documents, SelectionKind.BASE, temporary_path)
130
+ return tuple(
131
+ each_diagnostic.location.path
132
+ for each_diagnostic in adapters.test_pairing_diagnostics(document_set)
133
+ )
134
+
135
+
136
+ def test_pairing_accepts_an_approved_module_with_its_changed_suite(
137
+ tmp_path: Path,
138
+ ) -> None:
139
+ all_paths = _diagnostic_paths(
140
+ tmp_path,
141
+ _body_change_at(_APPROVED_PRODUCTION_PATH),
142
+ _body_change_at(_APPROVED_TEST_PATH),
143
+ )
144
+ assert all_paths == ()
145
+
146
+
147
+ def test_pairing_rejects_an_approved_module_without_its_suite(
148
+ tmp_path: Path,
149
+ ) -> None:
150
+ all_paths = _diagnostic_paths(tmp_path, _body_change_at(_APPROVED_PRODUCTION_PATH))
151
+ assert all_paths == (_APPROVED_PRODUCTION_PATH,)
152
+
153
+
154
+ def test_pairing_rejects_an_unrelated_module_beside_a_changed_approved_suite(
155
+ tmp_path: Path,
156
+ ) -> None:
157
+ all_paths = _diagnostic_paths(
158
+ tmp_path,
159
+ _body_change_at(_APPROVED_PRODUCTION_PATH),
160
+ _body_change_at(_UNRELATED_PRODUCTION_PATH),
161
+ _body_change_at(_APPROVED_TEST_PATH),
162
+ )
163
+ assert all_paths == (_UNRELATED_PRODUCTION_PATH,)