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,89 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Literal
5
+
6
+ MANIFEST_VERSION = 1
7
+ MANIFEST_VERSION_KEY = "version"
8
+ MANIFEST_CHECKS_KEY = "checks"
9
+ MANIFEST_EXCLUSIONS_KEY = "exclusions"
10
+ CHECK_ID_KEY = "id"
11
+ CHECK_ARGUMENTS_KEY = "argv"
12
+ CHECK_DIRECTORY_KEY = "cwd"
13
+ CHECK_TIMEOUT_KEY = "timeout_seconds"
14
+ CHECK_MINIMUM_TESTS_KEY = "minimum_tests"
15
+ EXCLUSION_SELECTOR_KEY = "selector"
16
+ EXCLUSION_REASON_KEY = "reason"
17
+ MANIFEST_DIGEST_ALGORITHM = "sha256"
18
+ ALL_MANIFEST_DIGEST_SEPARATORS = (",", ":")
19
+ SUCCESS_EXIT_CODE = 0
20
+ CHECK_FAILED_EXIT_CODE = 1
21
+ INVALID_INPUT_EXIT_CODE = 2
22
+ INCOMPLETE_EXIT_CODE = 3
23
+ UTF8_ENCODING = "utf-8"
24
+ JSON_INDENT_SPACES = 2
25
+ PASSED_STATUS = "passed"
26
+ FAILED_STATUS = "failed"
27
+ INCOMPLETE_STATUS = "incomplete"
28
+ MINIMUM_TESTS_ERROR_KIND = "minimum_tests"
29
+ MISSING_TOOL_ERROR_KIND = "missing_tool"
30
+ TIMEOUT_ERROR_KIND = "timeout"
31
+ CRASH_ERROR_KIND = "crash"
32
+ START_ERROR_KIND = "start_error"
33
+ COLLECTION_ERROR_KIND = "collection_error"
34
+ NONZERO_EXIT_ERROR_KIND = "nonzero_exit"
35
+ SKIPPED_TESTS_ERROR_KIND = "skipped_tests"
36
+ PYTEST_COLLECTION_FLAG = "--collect-only"
37
+ PYTEST_QUIET_FLAG = "-q"
38
+ COLLECTED_TESTS_PATTERN = re.compile(r"(?P<count>\d+) tests? collected")
39
+ SKIPPED_TESTS_PATTERN = re.compile(r"(?P<count>\d+) skipped")
40
+ SAFE_LOG_NAME_PATTERN = re.compile(r"[^A-Za-z0-9_.-]+")
41
+ PYTHON_PLACEHOLDER = "{python}"
42
+ REPOSITORY_PLACEHOLDER = "{repo}"
43
+ BASE_PLACEHOLDER = "{base}"
44
+ CDE_LINT_PLACEHOLDER = "{cde_lint}"
45
+ REPOSITORY_POLICY_PLACEHOLDER = "{repository_policy}"
46
+ LOGS_DIRECTORY_SUFFIX = ".logs"
47
+ STDOUT_LOG_SUFFIX = ".stdout.log"
48
+ STDERR_LOG_SUFFIX = ".stderr.log"
49
+ COLLECTION_STDOUT_LOG_SUFFIX = ".collection.stdout.log"
50
+ COLLECTION_STDERR_LOG_SUFFIX = ".collection.stderr.log"
51
+ REPORT_NEWLINE = "\n"
52
+ GIT_EXECUTABLE = "git"
53
+ ALL_GIT_ROOT_ARGUMENTS = ("rev-parse", "--show-toplevel")
54
+ ALL_GIT_HEAD_ARGUMENTS = ("rev-parse", "--verify", "HEAD")
55
+ ALL_GIT_TREE_ARGUMENTS = ("rev-parse", "--verify", "HEAD^{tree}")
56
+ ALL_GIT_BASE_ARGUMENT_PREFIX = ("rev-parse", "--verify")
57
+ ALL_GIT_STATUS_ARGUMENTS = (
58
+ "status",
59
+ "--porcelain=v1",
60
+ "--untracked-files=all",
61
+ )
62
+ ALL_GIT_CHANGED_FILES_ARGUMENTS = ("diff", "--name-only", "-z")
63
+ ALL_GIT_INDEX_CHANGED_FILES_ARGUMENTS = (
64
+ "diff",
65
+ "--cached",
66
+ "--name-only",
67
+ "-z",
68
+ )
69
+ ALL_GIT_UNTRACKED_FILES_ARGUMENTS = (
70
+ "ls-files",
71
+ "--others",
72
+ "--exclude-standard",
73
+ "-z",
74
+ )
75
+ GIT_COMMIT_OBJECT_SUFFIX = "^{commit}"
76
+ GIT_PATH_SEPARATOR = b"\0"
77
+ DIGEST_LENGTH_BYTES = 8
78
+ DIGEST_BYTE_ORDER: Literal["big"] = "big"
79
+ CLI_START_MESSAGE_TEMPLATE = "verification start: {report_path}"
80
+ CLI_CHECK_START_MESSAGE_TEMPLATE = "check start: {check_id}"
81
+ CLI_CHECK_FINISH_MESSAGE_TEMPLATE = "check finish: {check_id} status={status}"
82
+ CLI_AGGREGATE_MESSAGE_TEMPLATE = "aggregate: {status}"
83
+ CLI_REVISION_MESSAGE_TEMPLATE = "revision: head={head} base={base}"
84
+ CLI_ELIGIBILITY_MESSAGE_TEMPLATE = "eligibility: clean={worktree_clean} unchanged={inputs_unchanged} publishable={publishable}"
85
+ CLI_REPORT_MESSAGE_TEMPLATE = "report: {report_path}"
86
+ CLI_FAILURE_MESSAGE_TEMPLATE = "verification {status}: required checks did not pass"
87
+ UNRESOLVED_REVISION = "unresolved"
88
+ RUN_LOG_FILENAME = "run.log"
89
+ RUN_CHECK_LOG_TEMPLATE = "check {check_id} status={status}"
@@ -0,0 +1 @@
1
+ GIT_METADATA_TIMEOUT_SECONDS = 5.0
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .config import (
9
+ ALL_GIT_BASE_ARGUMENT_PREFIX,
10
+ ALL_GIT_CHANGED_FILES_ARGUMENTS,
11
+ ALL_GIT_HEAD_ARGUMENTS,
12
+ ALL_GIT_INDEX_CHANGED_FILES_ARGUMENTS,
13
+ ALL_GIT_ROOT_ARGUMENTS,
14
+ ALL_GIT_STATUS_ARGUMENTS,
15
+ ALL_GIT_TREE_ARGUMENTS,
16
+ ALL_GIT_UNTRACKED_FILES_ARGUMENTS,
17
+ DIGEST_BYTE_ORDER,
18
+ DIGEST_LENGTH_BYTES,
19
+ GIT_COMMIT_OBJECT_SUFFIX,
20
+ GIT_EXECUTABLE,
21
+ GIT_PATH_SEPARATOR,
22
+ UTF8_ENCODING,
23
+ )
24
+ from .config.timing import GIT_METADATA_TIMEOUT_SECONDS
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CandidateSnapshot:
29
+ head_revision: str | None
30
+ base_revision: str | None
31
+ is_git_repository: bool
32
+ worktree_clean: bool
33
+ input_digest: str | None
34
+
35
+
36
+ def capture_candidate_snapshot(
37
+ repository_path: Path, base_revision: str
38
+ ) -> CandidateSnapshot:
39
+ """Capture Git revisions and candidate file contents at one point.
40
+
41
+ Args:
42
+ repository_path: Candidate repository root.
43
+ base_revision: Base revision supplied to the verification run.
44
+
45
+ Returns:
46
+ Git revision and candidate content facts for the snapshot.
47
+ """
48
+ git_root = _run_git_text(repository_path, ALL_GIT_ROOT_ARGUMENTS)
49
+ if git_root is None or Path(git_root).resolve() != repository_path.resolve():
50
+ return CandidateSnapshot(None, None, False, False, None)
51
+ head_revision = _run_git_text(repository_path, ALL_GIT_HEAD_ARGUMENTS)
52
+ resolved_base_revision = _run_git_text(
53
+ repository_path,
54
+ (*ALL_GIT_BASE_ARGUMENT_PREFIX, base_revision + GIT_COMMIT_OBJECT_SUFFIX),
55
+ )
56
+ status_bytes = _run_git_bytes(repository_path, ALL_GIT_STATUS_ARGUMENTS)
57
+ is_worktree_clean = status_bytes == b""
58
+ return CandidateSnapshot(
59
+ head_revision,
60
+ resolved_base_revision,
61
+ True,
62
+ is_worktree_clean,
63
+ _compute_input_digest(repository_path, is_worktree_clean),
64
+ )
65
+
66
+
67
+ def _run_git_text(repository_path: Path, all_arguments: tuple[str, ...]) -> str | None:
68
+ command_bytes = _run_git_bytes(repository_path, all_arguments)
69
+ if command_bytes is None:
70
+ return None
71
+ revision_text = command_bytes.decode(UTF8_ENCODING).strip()
72
+ return revision_text or None
73
+
74
+
75
+ def _run_git_bytes(
76
+ repository_path: Path, all_arguments: tuple[str, ...]
77
+ ) -> bytes | None:
78
+ try:
79
+ completed_process = subprocess.run(
80
+ [GIT_EXECUTABLE, *all_arguments],
81
+ cwd=repository_path,
82
+ capture_output=True,
83
+ check=False,
84
+ timeout=GIT_METADATA_TIMEOUT_SECONDS,
85
+ )
86
+ except (OSError, subprocess.SubprocessError):
87
+ return None
88
+ if completed_process.returncode != 0:
89
+ return None
90
+ return completed_process.stdout
91
+
92
+
93
+ def _compute_input_digest(repository_path: Path, is_worktree_clean: bool) -> str | None:
94
+ head_tree = _run_git_text(repository_path, ALL_GIT_TREE_ARGUMENTS)
95
+ if head_tree is None:
96
+ return None
97
+ input_digest = hashlib.sha256()
98
+ _add_digest_record(input_digest, head_tree.encode(UTF8_ENCODING))
99
+ if is_worktree_clean:
100
+ return input_digest.hexdigest()
101
+ all_changed_paths = _changed_input_paths(repository_path)
102
+ if all_changed_paths is None:
103
+ return None
104
+ for each_path_bytes in all_changed_paths:
105
+ input_path = repository_path / Path(each_path_bytes.decode(UTF8_ENCODING))
106
+ input_bytes = _read_input_bytes(input_path)
107
+ if input_bytes is None:
108
+ return None
109
+ _add_digest_record(input_digest, each_path_bytes)
110
+ _add_digest_record(input_digest, input_bytes)
111
+ return input_digest.hexdigest()
112
+
113
+
114
+ def _changed_input_paths(repository_path: Path) -> tuple[bytes, ...] | None:
115
+ all_path_bytes: set[bytes] = set()
116
+ for each_arguments in (
117
+ ALL_GIT_CHANGED_FILES_ARGUMENTS,
118
+ ALL_GIT_INDEX_CHANGED_FILES_ARGUMENTS,
119
+ ALL_GIT_UNTRACKED_FILES_ARGUMENTS,
120
+ ):
121
+ changed_path_bytes = _run_git_bytes(repository_path, each_arguments)
122
+ if changed_path_bytes is None:
123
+ return None
124
+ all_path_bytes.update(
125
+ each_path_bytes
126
+ for each_path_bytes in changed_path_bytes.split(GIT_PATH_SEPARATOR)
127
+ if each_path_bytes
128
+ )
129
+ return tuple(sorted(all_path_bytes))
130
+
131
+
132
+ def _add_digest_record(input_digest: hashlib._Hash, record_bytes: bytes) -> None:
133
+ input_digest.update(
134
+ len(record_bytes).to_bytes(
135
+ DIGEST_LENGTH_BYTES,
136
+ byteorder=DIGEST_BYTE_ORDER,
137
+ )
138
+ )
139
+ input_digest.update(record_bytes)
140
+
141
+
142
+ def _read_input_bytes(input_path: Path) -> bytes | None:
143
+ try:
144
+ return input_path.read_bytes()
145
+ except OSError:
146
+ return None
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ from collections.abc import Mapping
7
+ from pathlib import Path, PurePosixPath, PureWindowsPath
8
+
9
+ from .config import (
10
+ ALL_MANIFEST_DIGEST_SEPARATORS,
11
+ CHECK_ARGUMENTS_KEY,
12
+ CHECK_DIRECTORY_KEY,
13
+ CHECK_ID_KEY,
14
+ CHECK_MINIMUM_TESTS_KEY,
15
+ CHECK_TIMEOUT_KEY,
16
+ EXCLUSION_REASON_KEY,
17
+ EXCLUSION_SELECTOR_KEY,
18
+ MANIFEST_CHECKS_KEY,
19
+ MANIFEST_DIGEST_ALGORITHM,
20
+ MANIFEST_EXCLUSIONS_KEY,
21
+ MANIFEST_VERSION,
22
+ MANIFEST_VERSION_KEY,
23
+ UTF8_ENCODING,
24
+ )
25
+ from .model import CheckSpec, ExclusionSpec, VerificationManifest
26
+
27
+
28
+ class ManifestRunFatal(ValueError):
29
+ """Raised when a verification manifest does not follow version 1."""
30
+
31
+
32
+ def compute_manifest_digest(manifest: VerificationManifest) -> str:
33
+ """Return a stable digest for the validated manifest structure.
34
+
35
+ Args:
36
+ manifest: Validated required-check manifest.
37
+
38
+ Returns:
39
+ A SHA-256 digest that covers every executed manifest field.
40
+ """
41
+ canonical_manifest = json.dumps(
42
+ _manifest_mapping(manifest),
43
+ sort_keys=True,
44
+ separators=ALL_MANIFEST_DIGEST_SEPARATORS,
45
+ )
46
+ return hashlib.new(
47
+ MANIFEST_DIGEST_ALGORITHM, canonical_manifest.encode(UTF8_ENCODING)
48
+ ).hexdigest()
49
+
50
+
51
+ def _manifest_mapping(manifest: VerificationManifest) -> dict[str, object]:
52
+ all_check_mappings = [
53
+ {
54
+ CHECK_ID_KEY: each_check.check_id,
55
+ CHECK_ARGUMENTS_KEY: list(each_check.command_arguments),
56
+ CHECK_DIRECTORY_KEY: each_check.cwd,
57
+ CHECK_TIMEOUT_KEY: each_check.timeout_seconds,
58
+ CHECK_MINIMUM_TESTS_KEY: each_check.minimum_tests,
59
+ }
60
+ for each_check in manifest.checks
61
+ ]
62
+ all_exclusion_mappings = [
63
+ {
64
+ EXCLUSION_SELECTOR_KEY: each_exclusion.selector,
65
+ EXCLUSION_REASON_KEY: each_exclusion.reason,
66
+ }
67
+ for each_exclusion in manifest.exclusions
68
+ ]
69
+ return {
70
+ MANIFEST_VERSION_KEY: manifest.version,
71
+ MANIFEST_CHECKS_KEY: all_check_mappings,
72
+ MANIFEST_EXCLUSIONS_KEY: all_exclusion_mappings,
73
+ }
74
+
75
+
76
+ def load_manifest(manifest_path: Path) -> VerificationManifest:
77
+ """Read and validate a required-check manifest.
78
+
79
+ Args:
80
+ manifest_path: JSON file containing the required checks.
81
+
82
+ Returns:
83
+ The validated manifest.
84
+
85
+ Raises:
86
+ ManifestRunFatal: If the file cannot describe a valid manifest.
87
+ OSError: If the file cannot be read.
88
+ """
89
+ try:
90
+ parsed_manifest: object = json.loads(
91
+ manifest_path.read_text(encoding=UTF8_ENCODING)
92
+ )
93
+ except json.JSONDecodeError as error:
94
+ raise ManifestRunFatal(f"Manifest is not valid JSON: {error}") from error
95
+ manifest_mapping = _require_mapping(parsed_manifest, "manifest")
96
+ manifest_version = _require_integer(manifest_mapping, "version", "manifest")
97
+ if manifest_version != MANIFEST_VERSION:
98
+ raise ManifestRunFatal(f"Manifest version must be {MANIFEST_VERSION}")
99
+ all_checks = _load_checks(manifest_mapping.get("checks"))
100
+ all_exclusions = _load_exclusions(manifest_mapping.get("exclusions", []))
101
+ return VerificationManifest(
102
+ manifest_version, tuple(all_checks), tuple(all_exclusions)
103
+ )
104
+
105
+
106
+ def _load_checks(raw_checks: object) -> list[CheckSpec]:
107
+ if not isinstance(raw_checks, list) or not raw_checks:
108
+ raise ManifestRunFatal("Manifest checks must be a non-empty array")
109
+ all_checks: list[CheckSpec] = []
110
+ all_check_ids: set[str] = set()
111
+ for each_index, each_raw_check in enumerate(raw_checks):
112
+ all_check_fields = _require_mapping(each_raw_check, f"checks[{each_index}]")
113
+ check_spec = _load_check(all_check_fields, each_index)
114
+ if check_spec.check_id in all_check_ids:
115
+ raise ManifestRunFatal("Check ids must be unique")
116
+ all_check_ids.add(check_spec.check_id)
117
+ all_checks.append(check_spec)
118
+ return all_checks
119
+
120
+
121
+ def _load_check(all_check_fields: Mapping[str, object], check_index: int) -> CheckSpec:
122
+ location = f"checks[{check_index}]"
123
+ check_id = _require_text(all_check_fields, "id", location)
124
+ all_command_arguments = _load_command_arguments(
125
+ all_check_fields.get("argv"), location
126
+ )
127
+ cwd = _load_relative_directory(all_check_fields.get("cwd"), location)
128
+ timeout_seconds = _load_timeout(all_check_fields.get("timeout_seconds"), location)
129
+ minimum_tests = _load_minimum_tests(all_check_fields.get("minimum_tests"), location)
130
+ return CheckSpec(
131
+ check_id, all_command_arguments, cwd, timeout_seconds, minimum_tests
132
+ )
133
+
134
+
135
+ def _load_command_arguments(raw_arguments: object, location: str) -> tuple[str, ...]:
136
+ if not isinstance(raw_arguments, list) or not raw_arguments:
137
+ raise ManifestRunFatal(f"{location}.argv must be a non-empty array")
138
+ if any(
139
+ not isinstance(each_argument, str) or not each_argument
140
+ for each_argument in raw_arguments
141
+ ):
142
+ raise ManifestRunFatal(f"{location}.argv must contain non-empty strings")
143
+ return tuple(raw_arguments)
144
+
145
+
146
+ def _load_exclusions(raw_exclusions: object) -> list[ExclusionSpec]:
147
+ if not isinstance(raw_exclusions, list):
148
+ raise ManifestRunFatal("Manifest exclusions must be an array")
149
+ all_exclusions: list[ExclusionSpec] = []
150
+ for each_index, each_raw_exclusion in enumerate(raw_exclusions):
151
+ all_exclusion_fields = _require_mapping(
152
+ each_raw_exclusion, f"exclusions[{each_index}]"
153
+ )
154
+ location = f"exclusions[{each_index}]"
155
+ selector = _require_text(all_exclusion_fields, "selector", location)
156
+ reason = _require_text(all_exclusion_fields, "reason", location)
157
+ all_exclusions.append(ExclusionSpec(selector, reason))
158
+ return all_exclusions
159
+
160
+
161
+ def _load_relative_directory(raw_directory: object, location: str) -> str:
162
+ if not isinstance(raw_directory, str) or not raw_directory:
163
+ raise ManifestRunFatal(f"{location}.cwd must be a repo-relative directory")
164
+ return _validate_relative_directory(raw_directory, location)
165
+
166
+
167
+ def _validate_relative_directory(directory_text: str, location: str) -> str:
168
+ normalized_directory = directory_text.replace("\\", "/")
169
+ directory_path = PurePosixPath(normalized_directory)
170
+ if (
171
+ directory_path.is_absolute()
172
+ or normalized_directory.startswith("/")
173
+ or PureWindowsPath(normalized_directory).drive
174
+ or any(each_part == ".." for each_part in directory_path.parts)
175
+ ):
176
+ raise ManifestRunFatal(f"{location}.cwd must stay inside the repository")
177
+ return directory_path.as_posix()
178
+
179
+
180
+ def _load_timeout(raw_timeout: object, location: str) -> float:
181
+ if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)):
182
+ raise ManifestRunFatal(f"{location}.timeout_seconds must be positive")
183
+ timeout_seconds = float(raw_timeout)
184
+ if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
185
+ raise ManifestRunFatal(f"{location}.timeout_seconds must be positive")
186
+ return timeout_seconds
187
+
188
+
189
+ def _load_minimum_tests(raw_minimum: object, location: str) -> int | None:
190
+ if raw_minimum is None:
191
+ return None
192
+ if (
193
+ isinstance(raw_minimum, bool)
194
+ or not isinstance(raw_minimum, int)
195
+ or raw_minimum < 0
196
+ ):
197
+ raise ManifestRunFatal(
198
+ f"{location}.minimum_tests must be a non-negative integer"
199
+ )
200
+ return raw_minimum
201
+
202
+
203
+ def _require_mapping(candidate: object, location: str) -> Mapping[str, object]:
204
+ if not isinstance(candidate, Mapping):
205
+ raise ManifestRunFatal(f"{location} must be an object")
206
+ return candidate
207
+
208
+
209
+ def _require_text(
210
+ all_fields: Mapping[str, object], field_name: str, location: str
211
+ ) -> str:
212
+ field_text = all_fields.get(field_name)
213
+ if not isinstance(field_text, str) or not field_text.strip():
214
+ raise ManifestRunFatal(f"{location}.{field_name} must be a non-empty string")
215
+ return field_text
216
+
217
+
218
+ def _require_integer(
219
+ all_fields: Mapping[str, object], field_name: str, location: str
220
+ ) -> int:
221
+ field_number = all_fields.get(field_name)
222
+ if isinstance(field_number, bool) or not isinstance(field_number, int):
223
+ raise ManifestRunFatal(f"{location}.{field_name} must be an integer")
224
+ return field_number
@@ -0,0 +1,174 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import TypeAlias
6
+
7
+ from .config import (
8
+ CHECK_FAILED_EXIT_CODE,
9
+ FAILED_STATUS,
10
+ INCOMPLETE_EXIT_CODE,
11
+ INCOMPLETE_STATUS,
12
+ PASSED_STATUS,
13
+ SUCCESS_EXIT_CODE,
14
+ )
15
+
16
+ CheckStatus: TypeAlias = str
17
+ AggregateStatus: TypeAlias = str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CheckSpec:
22
+ check_id: str
23
+ command_arguments: tuple[str, ...]
24
+ cwd: str
25
+ timeout_seconds: float
26
+ minimum_tests: int | None = None
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ExclusionSpec:
31
+ selector: str
32
+ reason: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class VerificationManifest:
37
+ version: int
38
+ checks: tuple[CheckSpec, ...]
39
+ exclusions: tuple[ExclusionSpec, ...]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CommandCapture:
44
+ exit_code: int | None
45
+ stdout_text: str
46
+ stderr_text: str
47
+ error_kind: str | None
48
+ error_message: str | None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CheckLogPaths:
53
+ stdout: Path
54
+ stderr: Path
55
+ collection_stdout: Path
56
+ collection_stderr: Path
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class CheckRecord:
61
+ check_id: str
62
+ status: CheckStatus
63
+ command_arguments: tuple[str, ...]
64
+ cwd: str
65
+ exit_code: int | None
66
+ duration_seconds: float
67
+ stdout_log: Path
68
+ stderr_log: Path
69
+ error_kind: str | None = None
70
+ error_message: str | None = None
71
+ minimum_tests: int | None = None
72
+ collected_tests: int | None = None
73
+ collection_exit_code: int | None = None
74
+
75
+ def as_dict(self) -> dict[str, object]:
76
+ """Return the JSON record for this check.
77
+
78
+ Returns:
79
+ A JSON-compatible check record using the manifest argv key.
80
+ """
81
+ return {
82
+ "id": self.check_id,
83
+ "status": self.status,
84
+ "argv": list(self.command_arguments),
85
+ "cwd": self.cwd,
86
+ "exit_code": self.exit_code,
87
+ "duration_seconds": self.duration_seconds,
88
+ "stdout_log": str(self.stdout_log),
89
+ "stderr_log": str(self.stderr_log),
90
+ "error_kind": self.error_kind,
91
+ "error_message": self.error_message,
92
+ "minimum_tests": self.minimum_tests,
93
+ "collected_tests": self.collected_tests,
94
+ "collection_exit_code": self.collection_exit_code,
95
+ }
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class VerificationReport:
100
+ version: int
101
+ repository: Path
102
+ base_revision: str | None
103
+ checks: tuple[CheckRecord, ...]
104
+ exclusions: tuple[ExclusionSpec, ...]
105
+ head_revision: str | None = None
106
+ manifest_digest: str = ""
107
+ is_worktree_clean: bool = False
108
+ is_inputs_unchanged: bool = False
109
+ is_publishable: bool = False
110
+
111
+ @property
112
+ def worktree_clean(self) -> bool:
113
+ return self.is_worktree_clean
114
+
115
+ @property
116
+ def inputs_unchanged(self) -> bool:
117
+ return self.is_inputs_unchanged
118
+
119
+ @property
120
+ def publishable(self) -> bool:
121
+ return self.is_publishable
122
+
123
+ @property
124
+ def aggregate_status(self) -> AggregateStatus:
125
+ if any(each_record.status == FAILED_STATUS for each_record in self.checks):
126
+ return FAILED_STATUS
127
+ if any(each_record.status == INCOMPLETE_STATUS for each_record in self.checks):
128
+ return INCOMPLETE_STATUS
129
+ return PASSED_STATUS
130
+
131
+ @property
132
+ def exit_code(self) -> int:
133
+ if self.aggregate_status == FAILED_STATUS:
134
+ return CHECK_FAILED_EXIT_CODE
135
+ if self.aggregate_status == INCOMPLETE_STATUS:
136
+ return INCOMPLETE_EXIT_CODE
137
+ return SUCCESS_EXIT_CODE
138
+
139
+ def _aggregate_fields(self, all_statuses: list[str]) -> dict[str, object]:
140
+ return {
141
+ "status": self.aggregate_status,
142
+ "total": len(self.checks),
143
+ "passed": all_statuses.count(PASSED_STATUS),
144
+ "failed": all_statuses.count(FAILED_STATUS),
145
+ "incomplete": all_statuses.count(INCOMPLETE_STATUS),
146
+ "exit_code": self.exit_code,
147
+ }
148
+
149
+ def as_dict(self) -> dict[str, object]:
150
+ """Return the JSON report for this verification run.
151
+
152
+ Returns:
153
+ A JSON-compatible report with aggregate check counts.
154
+ """
155
+ all_statuses = [each_record.status for each_record in self.checks]
156
+ return {
157
+ "version": self.version,
158
+ "schema_version": self.version,
159
+ "repository": str(self.repository),
160
+ "head": self.head_revision,
161
+ "base": self.base_revision,
162
+ "manifest_digest": self.manifest_digest,
163
+ "worktree_clean": self.is_worktree_clean,
164
+ "inputs_unchanged": self.is_inputs_unchanged,
165
+ "publishable": self.is_publishable,
166
+ "checks": [each_record.as_dict() for each_record in self.checks],
167
+ "exclusions": [
168
+ {"selector": each_exclusion.selector, "reason": each_exclusion.reason}
169
+ for each_exclusion in self.exclusions
170
+ ],
171
+ "aggregate": self._aggregate_fields(all_statuses),
172
+ "status": self.aggregate_status,
173
+ "exit_code": self.exit_code,
174
+ }