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,247 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from local_report_validation import (
9
+ ReportDecision,
10
+ _load_all_report_fields,
11
+ _matches_candidate,
12
+ _validate_report,
13
+ )
14
+ from local_verification.git_state import CandidateSnapshot, capture_candidate_snapshot
15
+ from local_verification.manifest import ManifestRunFatal, load_manifest
16
+ from local_verification.model import VerificationManifest
17
+ from pr_verification.config.constants import (
18
+ ERROR_DESCRIPTION,
19
+ LOCAL_CHECKS_CONTEXT,
20
+ LOCAL_CHECKS_PASSED_LABEL,
21
+ PENDING_DESCRIPTION,
22
+ )
23
+ from pr_verification.github import GitHubApi
24
+ from pr_verification.model import (
25
+ PullRequestCandidate,
26
+ RepositorySettings,
27
+ StatusState,
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PublicationOutcome:
33
+ status: StatusState
34
+ description: str
35
+ publishable: bool
36
+
37
+
38
+ def publish_local_report(
39
+ github: GitHubApi,
40
+ repository: RepositorySettings,
41
+ pull_request_number: int,
42
+ local_repository_path: Path,
43
+ manifest_path: Path,
44
+ report_path: Path,
45
+ ) -> PublicationOutcome:
46
+ """Publish one local report.
47
+
48
+ Args:
49
+ github: GitHub API client.
50
+ repository: Repository settings.
51
+ pull_request_number: Pull request number that owns the report.
52
+ local_repository_path: Local checkout that produced the report.
53
+ manifest_path: Verification manifest path.
54
+ report_path: Verification report path.
55
+
56
+ Returns:
57
+ The publication outcome.
58
+ """
59
+ return _publish_report_inputs(
60
+ github,
61
+ repository,
62
+ pull_request_number,
63
+ local_repository_path,
64
+ _load_report_inputs(manifest_path, report_path),
65
+ )
66
+
67
+
68
+ def _publish_report_inputs(
69
+ github: GitHubApi,
70
+ repository: RepositorySettings,
71
+ pull_request_number: int,
72
+ local_repository_path: Path,
73
+ all_report_inputs: tuple[VerificationManifest, Mapping[str, object]] | None,
74
+ ) -> PublicationOutcome:
75
+ candidate = github.get_candidate(
76
+ repository, pull_request_number, should_require_merge_commit=False
77
+ )
78
+ if all_report_inputs is None:
79
+ return _publish_advisory(
80
+ github, repository, candidate, StatusState.PENDING, PENDING_DESCRIPTION
81
+ )
82
+ manifest, all_report_fields = all_report_inputs
83
+ return _publish_loaded_report(
84
+ github,
85
+ repository,
86
+ pull_request_number,
87
+ local_repository_path,
88
+ manifest,
89
+ all_report_fields,
90
+ candidate,
91
+ )
92
+
93
+
94
+ def _load_report_inputs(
95
+ manifest_path: Path, report_path: Path
96
+ ) -> tuple[VerificationManifest, Mapping[str, object]] | None:
97
+ try:
98
+ manifest = load_manifest(manifest_path)
99
+ all_report_fields = _load_all_report_fields(report_path)
100
+ except (
101
+ ManifestRunFatal,
102
+ OSError,
103
+ UnicodeDecodeError,
104
+ json.JSONDecodeError,
105
+ TypeError,
106
+ ValueError,
107
+ ):
108
+ return None
109
+ return manifest, all_report_fields
110
+
111
+
112
+ def _publish_loaded_report(
113
+ github: GitHubApi,
114
+ repository: RepositorySettings,
115
+ pull_request_number: int,
116
+ local_repository_path: Path,
117
+ manifest: VerificationManifest,
118
+ all_report_fields: Mapping[str, object],
119
+ candidate: PullRequestCandidate,
120
+ ) -> PublicationOutcome:
121
+ decision = _validate_report(
122
+ manifest, all_report_fields, candidate, local_repository_path
123
+ )
124
+ if not decision.publishable:
125
+ return _publish_advisory(
126
+ github, repository, candidate, decision.status, decision.description
127
+ )
128
+ return _publish_success(
129
+ github,
130
+ repository,
131
+ pull_request_number,
132
+ local_repository_path,
133
+ candidate,
134
+ decision,
135
+ )
136
+
137
+
138
+ def _publish_advisory(
139
+ github: GitHubApi,
140
+ repository: RepositorySettings,
141
+ candidate: PullRequestCandidate,
142
+ status: StatusState,
143
+ description: str,
144
+ ) -> PublicationOutcome:
145
+ github.remove_label(
146
+ repository, candidate.pull_request_number, LOCAL_CHECKS_PASSED_LABEL
147
+ )
148
+ github.post_status(
149
+ repository, candidate.head_sha, status, LOCAL_CHECKS_CONTEXT, description
150
+ )
151
+ return PublicationOutcome(status, description, False)
152
+
153
+
154
+ def _publish_success(
155
+ github: GitHubApi,
156
+ repository: RepositorySettings,
157
+ pull_request_number: int,
158
+ local_repository_path: Path,
159
+ candidate: PullRequestCandidate,
160
+ decision: ReportDecision,
161
+ ) -> PublicationOutcome:
162
+ latest_candidate, latest_snapshot = _read_current_candidate(
163
+ github, repository, pull_request_number, local_repository_path
164
+ )
165
+ if not _matches_candidate(
166
+ latest_snapshot,
167
+ latest_candidate,
168
+ candidate.head_sha,
169
+ candidate.base_sha,
170
+ ):
171
+ return _publish_advisory(
172
+ github, repository, latest_candidate, StatusState.ERROR, ERROR_DESCRIPTION
173
+ )
174
+ _post_success(github, repository, pull_request_number, latest_candidate, decision)
175
+ return _confirm_success(
176
+ github,
177
+ repository,
178
+ pull_request_number,
179
+ local_repository_path,
180
+ latest_candidate,
181
+ decision,
182
+ )
183
+
184
+
185
+ def _read_current_candidate(
186
+ github: GitHubApi,
187
+ repository: RepositorySettings,
188
+ pull_request_number: int,
189
+ local_repository_path: Path,
190
+ ) -> tuple[PullRequestCandidate, CandidateSnapshot]:
191
+ candidate = github.get_candidate(
192
+ repository, pull_request_number, should_require_merge_commit=False
193
+ )
194
+ snapshot = capture_candidate_snapshot(local_repository_path, candidate.base_sha)
195
+ return candidate, snapshot
196
+
197
+
198
+ def _post_success(
199
+ github: GitHubApi,
200
+ repository: RepositorySettings,
201
+ pull_request_number: int,
202
+ candidate: PullRequestCandidate,
203
+ decision: ReportDecision,
204
+ ) -> None:
205
+ github.post_status(
206
+ repository,
207
+ candidate.head_sha,
208
+ StatusState.SUCCESS,
209
+ LOCAL_CHECKS_CONTEXT,
210
+ decision.description,
211
+ )
212
+ github.add_label(repository, pull_request_number, LOCAL_CHECKS_PASSED_LABEL)
213
+
214
+
215
+ def _confirm_success(
216
+ github: GitHubApi,
217
+ repository: RepositorySettings,
218
+ pull_request_number: int,
219
+ local_repository_path: Path,
220
+ candidate: PullRequestCandidate,
221
+ decision: ReportDecision,
222
+ ) -> PublicationOutcome:
223
+ final_candidate, final_snapshot = _read_current_candidate(
224
+ github, repository, pull_request_number, local_repository_path
225
+ )
226
+ if _matches_candidate(
227
+ final_snapshot,
228
+ final_candidate,
229
+ candidate.head_sha,
230
+ candidate.base_sha,
231
+ ):
232
+ return _decision_to_outcome(decision)
233
+ github.remove_label(repository, pull_request_number, LOCAL_CHECKS_PASSED_LABEL)
234
+ github.post_status(
235
+ repository,
236
+ final_candidate.head_sha,
237
+ StatusState.ERROR,
238
+ LOCAL_CHECKS_CONTEXT,
239
+ ERROR_DESCRIPTION,
240
+ )
241
+ return PublicationOutcome(StatusState.ERROR, ERROR_DESCRIPTION, False)
242
+
243
+
244
+ def _decision_to_outcome(decision: ReportDecision) -> PublicationOutcome:
245
+ return PublicationOutcome(
246
+ decision.status, decision.description, decision.publishable
247
+ )
@@ -0,0 +1,12 @@
1
+ import sys
2
+
3
+ import local_report_cli as report_cli
4
+ import local_report_core as report_core
5
+
6
+ main = report_cli.main
7
+ PublicationOutcome = report_core.PublicationOutcome
8
+ publish_local_report = report_core.publish_local_report
9
+
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,357 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from local_verification.check_support import _substitute_arguments
9
+ from local_verification.config import (
10
+ CHECK_FAILED_EXIT_CODE,
11
+ COLLECTION_ERROR_KIND,
12
+ FAILED_STATUS,
13
+ INCOMPLETE_EXIT_CODE,
14
+ INCOMPLETE_STATUS,
15
+ MINIMUM_TESTS_ERROR_KIND,
16
+ PASSED_STATUS,
17
+ SUCCESS_EXIT_CODE,
18
+ )
19
+ from local_verification.git_state import CandidateSnapshot, capture_candidate_snapshot
20
+ from local_verification.manifest import compute_manifest_digest
21
+ from local_verification.model import CheckSpec, VerificationManifest
22
+ from pr_verification.config.constants import ERROR_DESCRIPTION, FAILURE_DESCRIPTION
23
+ from pr_verification.model import PullRequestCandidate, StatusState
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ReportDecision:
28
+ status: StatusState
29
+ description: str
30
+ publishable: bool
31
+ selected_check_ids: tuple[str, ...] = ()
32
+
33
+
34
+ def _load_all_report_fields(report_path: Path) -> Mapping[str, object]:
35
+ parsed_report = json.loads(report_path.read_text(encoding="utf-8"))
36
+ if not isinstance(parsed_report, Mapping):
37
+ raise TypeError("Verification report must be an object")
38
+ return parsed_report
39
+
40
+
41
+ def _validate_report(
42
+ manifest: VerificationManifest,
43
+ all_report_fields: Mapping[str, object],
44
+ candidate: PullRequestCandidate,
45
+ local_repository_path: Path,
46
+ ) -> ReportDecision:
47
+ report_head = _text_field(all_report_fields, "head")
48
+ report_base = _text_field(all_report_fields, "base")
49
+ if report_head is None or report_base is None:
50
+ return _invalid_report()
51
+ status = _validate_all_check_fields(
52
+ all_report_fields, manifest.checks, local_repository_path, candidate.base_sha
53
+ )
54
+ if status != PASSED_STATUS:
55
+ return _status_decision(status)
56
+ if not _matches_report_metadata(all_report_fields, manifest):
57
+ return _invalid_report()
58
+ snapshot = capture_candidate_snapshot(local_repository_path, report_base)
59
+ if not _matches_candidate(snapshot, candidate, report_head, report_base):
60
+ return _invalid_report()
61
+ check_count = len(manifest.checks)
62
+ return ReportDecision(
63
+ StatusState.SUCCESS,
64
+ f"Selected local checks passed ({check_count} checks)",
65
+ True,
66
+ tuple(each_check.check_id for each_check in manifest.checks),
67
+ )
68
+
69
+
70
+ def _status_decision(status: str | None) -> ReportDecision:
71
+ if status == FAILED_STATUS:
72
+ return ReportDecision(StatusState.FAILURE, FAILURE_DESCRIPTION, False)
73
+ return ReportDecision(StatusState.ERROR, ERROR_DESCRIPTION, False)
74
+
75
+
76
+ def _matches_report_metadata(
77
+ all_report_fields: Mapping[str, object], manifest: VerificationManifest
78
+ ) -> bool:
79
+ return (
80
+ all_report_fields.get("manifest_digest") == compute_manifest_digest(manifest)
81
+ and all_report_fields.get("publishable") is True
82
+ and all_report_fields.get("worktree_clean") is True
83
+ and all_report_fields.get("inputs_unchanged") is True
84
+ )
85
+
86
+
87
+ def _validate_all_check_fields(
88
+ all_report_fields: Mapping[str, object],
89
+ all_manifest_checks: tuple[CheckSpec, ...],
90
+ local_repository_path: Path,
91
+ base_revision: str,
92
+ ) -> str | None:
93
+ all_statuses = _read_check_statuses(
94
+ all_report_fields, all_manifest_checks, local_repository_path, base_revision
95
+ )
96
+ if all_statuses is None:
97
+ return None
98
+ return _validate_aggregate(all_report_fields, all_statuses)
99
+
100
+
101
+ def _read_check_statuses(
102
+ all_report_fields: Mapping[str, object],
103
+ all_manifest_checks: tuple[CheckSpec, ...],
104
+ local_repository_path: Path,
105
+ base_revision: str,
106
+ ) -> list[str] | None:
107
+ raw_checks = all_report_fields.get("checks")
108
+ if not isinstance(raw_checks, list):
109
+ return None
110
+ all_check_ids: list[str] = []
111
+ all_statuses: list[str] = []
112
+ for each_index, each_raw_check in enumerate(raw_checks):
113
+ check_fields = _read_check_fields(
114
+ each_raw_check,
115
+ all_manifest_checks,
116
+ each_index,
117
+ local_repository_path,
118
+ base_revision,
119
+ )
120
+ if check_fields is None:
121
+ return None
122
+ check_id, status = check_fields
123
+ all_check_ids.append(check_id)
124
+ all_statuses.append(status)
125
+ if not _check_records_match_manifest(
126
+ all_check_ids, all_statuses, all_manifest_checks
127
+ ):
128
+ return None
129
+ return all_statuses
130
+
131
+
132
+ def _check_records_match_manifest(
133
+ all_check_ids: list[str],
134
+ all_statuses: list[str],
135
+ all_manifest_checks: tuple[CheckSpec, ...],
136
+ ) -> bool:
137
+ selected_check_ids = tuple(
138
+ each_check.check_id for each_check in all_manifest_checks
139
+ )
140
+ if tuple(all_check_ids) != selected_check_ids:
141
+ return False
142
+ return all(
143
+ each_status in {PASSED_STATUS, FAILED_STATUS, INCOMPLETE_STATUS}
144
+ for each_status in all_statuses
145
+ )
146
+
147
+
148
+ def _read_check_fields(
149
+ raw_check: object,
150
+ all_manifest_checks: tuple[CheckSpec, ...],
151
+ check_index: int,
152
+ local_repository_path: Path,
153
+ base_revision: str,
154
+ ) -> tuple[str, str] | None:
155
+ if not isinstance(raw_check, Mapping):
156
+ return None
157
+ check_id = raw_check.get("id")
158
+ status = raw_check.get("status")
159
+ if not isinstance(check_id, str) or not isinstance(status, str):
160
+ return None
161
+ if not _all_check_fields_matches_spec(
162
+ raw_check,
163
+ all_manifest_checks,
164
+ check_index,
165
+ status,
166
+ local_repository_path,
167
+ base_revision,
168
+ ):
169
+ return None
170
+ return check_id, status
171
+
172
+
173
+ def _validate_aggregate(
174
+ all_report_fields: Mapping[str, object], all_statuses: list[str]
175
+ ) -> str | None:
176
+ aggregate_status = _aggregate_status(all_statuses)
177
+ aggregate_mapping = all_report_fields.get("aggregate")
178
+ if not isinstance(aggregate_mapping, Mapping):
179
+ return None
180
+ if aggregate_mapping.get("status") != aggregate_status:
181
+ return None
182
+ if all_report_fields.get("status") != aggregate_status:
183
+ return None
184
+ expected_exit_code = _status_exit_code(aggregate_status)
185
+ if not _matches_integer(all_report_fields.get("exit_code"), expected_exit_code):
186
+ return None
187
+ expected_counts = {
188
+ "total": len(all_statuses),
189
+ "passed": all_statuses.count(PASSED_STATUS),
190
+ "failed": all_statuses.count(FAILED_STATUS),
191
+ "incomplete": all_statuses.count(INCOMPLETE_STATUS),
192
+ "exit_code": expected_exit_code,
193
+ }
194
+ if any(
195
+ not _matches_integer(aggregate_mapping.get(each_name), each_count)
196
+ for each_name, each_count in expected_counts.items()
197
+ ):
198
+ return None
199
+ return aggregate_status
200
+
201
+
202
+ def _all_check_fields_matches_spec(
203
+ all_check_fields: Mapping[str, object],
204
+ all_manifest_checks: tuple[CheckSpec, ...],
205
+ check_index: int,
206
+ status: str,
207
+ local_repository_path: Path,
208
+ base_revision: str,
209
+ ) -> bool:
210
+ if check_index >= len(all_manifest_checks):
211
+ return False
212
+ if not _matches_execution_fields(all_check_fields, status):
213
+ return False
214
+ check_spec = all_manifest_checks[check_index]
215
+ if not _matches_command_arguments(
216
+ all_check_fields, check_spec, local_repository_path, base_revision
217
+ ):
218
+ return False
219
+ if not _matches_check_directory(
220
+ all_check_fields, check_spec, local_repository_path
221
+ ):
222
+ return False
223
+ if all_check_fields.get("minimum_tests") != check_spec.minimum_tests:
224
+ return False
225
+ return _matches_collection_fields(all_check_fields, check_spec, status)
226
+
227
+
228
+ def _matches_command_arguments(
229
+ all_check_fields: Mapping[str, object],
230
+ check_spec: CheckSpec,
231
+ local_repository_path: Path,
232
+ base_revision: str,
233
+ ) -> bool:
234
+ all_recorded_arguments = all_check_fields.get("argv")
235
+ if not isinstance(all_recorded_arguments, list):
236
+ return False
237
+ if any(
238
+ not isinstance(each_argument, str) for each_argument in all_recorded_arguments
239
+ ):
240
+ return False
241
+ expected_arguments = _substitute_arguments(
242
+ check_spec.command_arguments, local_repository_path, base_revision
243
+ )
244
+ return tuple(all_recorded_arguments) == expected_arguments
245
+
246
+
247
+ def _matches_execution_fields(
248
+ all_check_fields: Mapping[str, object], status: str
249
+ ) -> bool:
250
+ exit_code = all_check_fields.get("exit_code")
251
+ error_kind = all_check_fields.get("error_kind")
252
+ error_message = all_check_fields.get("error_message")
253
+ if status == PASSED_STATUS:
254
+ return (
255
+ exit_code == SUCCESS_EXIT_CODE
256
+ and error_kind is None
257
+ and error_message is None
258
+ )
259
+ if not isinstance(error_kind, str) or not error_kind:
260
+ return False
261
+ if not isinstance(error_message, str) or not error_message:
262
+ return False
263
+ if status == FAILED_STATUS and error_kind == MINIMUM_TESTS_ERROR_KIND:
264
+ return exit_code == SUCCESS_EXIT_CODE
265
+ if status == FAILED_STATUS:
266
+ return _is_integer(exit_code) and exit_code != SUCCESS_EXIT_CODE
267
+ return status == INCOMPLETE_STATUS
268
+
269
+
270
+ def _matches_check_directory(
271
+ all_check_fields: Mapping[str, object],
272
+ check_spec: CheckSpec,
273
+ local_repository_path: Path,
274
+ ) -> bool:
275
+ expected_check_directory = (local_repository_path / check_spec.cwd).resolve()
276
+ recorded_check_directory = all_check_fields.get("cwd")
277
+ return (
278
+ isinstance(recorded_check_directory, str)
279
+ and Path(recorded_check_directory).resolve() == expected_check_directory
280
+ )
281
+
282
+
283
+ def _matches_collection_fields(
284
+ all_check_fields: Mapping[str, object], check_spec: CheckSpec, status: str
285
+ ) -> bool:
286
+ if check_spec.minimum_tests is None:
287
+ return True
288
+ collected_tests = _read_integer(all_check_fields.get("collected_tests"))
289
+ collection_exit_code = _read_integer(all_check_fields.get("collection_exit_code"))
290
+ if collected_tests is None or collection_exit_code is None:
291
+ return False
292
+ if status == PASSED_STATUS:
293
+ return (
294
+ collection_exit_code == SUCCESS_EXIT_CODE
295
+ and collected_tests >= check_spec.minimum_tests
296
+ )
297
+ if (
298
+ status == FAILED_STATUS
299
+ and all_check_fields.get("error_kind") == MINIMUM_TESTS_ERROR_KIND
300
+ ):
301
+ return collected_tests < check_spec.minimum_tests
302
+ if all_check_fields.get("error_kind") != COLLECTION_ERROR_KIND:
303
+ return True
304
+ return collection_exit_code != SUCCESS_EXIT_CODE
305
+
306
+
307
+ def _aggregate_status(all_statuses: list[str]) -> str:
308
+ if FAILED_STATUS in all_statuses:
309
+ return FAILED_STATUS
310
+ if INCOMPLETE_STATUS in all_statuses:
311
+ return INCOMPLETE_STATUS
312
+ return PASSED_STATUS
313
+
314
+
315
+ def _status_exit_code(status: str) -> int:
316
+ if status == FAILED_STATUS:
317
+ return CHECK_FAILED_EXIT_CODE
318
+ if status == INCOMPLETE_STATUS:
319
+ return INCOMPLETE_EXIT_CODE
320
+ return SUCCESS_EXIT_CODE
321
+
322
+
323
+ def _matches_candidate(
324
+ candidate_snapshot: CandidateSnapshot,
325
+ candidate: PullRequestCandidate,
326
+ report_head: str,
327
+ report_base: str,
328
+ ) -> bool:
329
+ return (
330
+ candidate_snapshot.is_git_repository
331
+ and candidate_snapshot.worktree_clean
332
+ and candidate_snapshot.head_revision == report_head == candidate.head_sha
333
+ and candidate_snapshot.base_revision == report_base == candidate.base_sha
334
+ )
335
+
336
+
337
+ def _read_integer(raw_integer: object) -> int | None:
338
+ if isinstance(raw_integer, bool) or not isinstance(raw_integer, int):
339
+ return None
340
+ return raw_integer
341
+
342
+
343
+ def _is_integer(raw_integer: object) -> bool:
344
+ return isinstance(raw_integer, int) and not isinstance(raw_integer, bool)
345
+
346
+
347
+ def _matches_integer(raw_integer: object, expected_integer: int) -> bool:
348
+ return _is_integer(raw_integer) and raw_integer == expected_integer
349
+
350
+
351
+ def _invalid_report() -> ReportDecision:
352
+ return ReportDecision(StatusState.ERROR, ERROR_DESCRIPTION, False)
353
+
354
+
355
+ def _text_field(all_report_fields: Mapping[str, object], field_name: str) -> str | None:
356
+ field_text = all_report_fields.get(field_name)
357
+ return field_text if isinstance(field_text, str) and field_text else None
@@ -0,0 +1,23 @@
1
+ from .cli import main
2
+ from .manifest import ManifestRunFatal, compute_manifest_digest, load_manifest
3
+ from .model import (
4
+ CheckRecord,
5
+ CheckSpec,
6
+ ExclusionSpec,
7
+ VerificationManifest,
8
+ VerificationReport,
9
+ )
10
+ from .runner import run_verification
11
+
12
+ __all__ = [
13
+ "CheckRecord",
14
+ "CheckSpec",
15
+ "ExclusionSpec",
16
+ "ManifestRunFatal",
17
+ "VerificationManifest",
18
+ "VerificationReport",
19
+ "compute_manifest_digest",
20
+ "load_manifest",
21
+ "main",
22
+ "run_verification",
23
+ ]