claude-dev-env 7.0.0 → 7.1.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 +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -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 +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -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,60 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ import verification_notice
8
+
9
+
10
+ def _record_notice_execution(
11
+ monkeypatch: pytest.MonkeyPatch,
12
+ ) -> tuple[io.StringIO, list[object]]:
13
+ notice_context = object()
14
+ all_started_contexts: list[object] = []
15
+ monkeypatch.setattr(
16
+ verification_notice,
17
+ "_load_notice_context",
18
+ lambda event, repository: notice_context,
19
+ )
20
+ monkeypatch.setattr(
21
+ verification_notice,
22
+ "build_verification_notice",
23
+ lambda context: "notice\n",
24
+ )
25
+ monkeypatch.setattr(
26
+ verification_notice,
27
+ "start_automatic_advisory",
28
+ all_started_contexts.append,
29
+ )
30
+ return io.StringIO(), all_started_contexts
31
+
32
+
33
+ def test_notice_only_prints_without_starting_verification(
34
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
35
+ ) -> None:
36
+ standard_output, all_started_contexts = _record_notice_execution(monkeypatch)
37
+
38
+ exit_code = verification_notice.main(
39
+ ["--event", "commit", "--notice-only", "--repo", str(tmp_path)],
40
+ stdout=standard_output,
41
+ )
42
+
43
+ assert exit_code == 0
44
+ assert standard_output.getvalue() == "notice\n"
45
+ assert all_started_contexts == []
46
+
47
+
48
+ def test_default_notice_prints_and_starts_verification(
49
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
50
+ ) -> None:
51
+ standard_output, all_started_contexts = _record_notice_execution(monkeypatch)
52
+
53
+ exit_code = verification_notice.main(
54
+ ["--event", "commit", "--repo", str(tmp_path)],
55
+ stdout=standard_output,
56
+ )
57
+
58
+ assert exit_code == 0
59
+ assert standard_output.getvalue() == "notice\n"
60
+ assert len(all_started_contexts) == 1
@@ -0,0 +1,173 @@
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[2] / "scripts"
11
+ if str(scripts_directory) not in sys.path:
12
+ sys.path.insert(0, str(scripts_directory))
13
+
14
+ from verification_notice import build_verification_notice
15
+ from verification_notice_context import VerificationNoticeContext
16
+ from verification_start import start_automatic_advisory
17
+
18
+
19
+ def build_context(
20
+ repository_root: Path,
21
+ *,
22
+ event: str = "commit",
23
+ repository_remote: str = "jonecho/python-automation",
24
+ ) -> VerificationNoticeContext:
25
+ git_directory = repository_root / ".git"
26
+ return VerificationNoticeContext(
27
+ event=event,
28
+ repository_remote=repository_remote,
29
+ repository_root=repository_root,
30
+ current_head="a" * 40,
31
+ manifest_path=None,
32
+ manifest_is_available=False,
33
+ git_directory=git_directory,
34
+ report_is_present=False,
35
+ all_report_fields=None,
36
+ )
37
+
38
+
39
+ def write_runner_configuration(repository_root: Path, configuration: object) -> None:
40
+ runner_path = repository_root / ".git" / "local-verification" / "runner.json"
41
+ runner_path.parent.mkdir(parents=True)
42
+ runner_path.write_text(json.dumps(configuration), encoding="utf-8")
43
+
44
+
45
+ def create_runner_configuration(repository_root: Path) -> tuple[str, str]:
46
+ python_path = str(repository_root / "venv" / "Scripts" / "python.exe")
47
+ settings_path = str(repository_root / "settings.json")
48
+ write_runner_configuration(
49
+ repository_root,
50
+ {"python": python_path, "settings": settings_path},
51
+ )
52
+ return python_path, settings_path
53
+
54
+
55
+ def test_start_automatic_advisory_launches_without_waiting(
56
+ tmp_path: Path,
57
+ monkeypatch: pytest.MonkeyPatch,
58
+ ) -> None:
59
+ python_path, settings_path = create_runner_configuration(tmp_path)
60
+
61
+ all_launches: list[tuple[object, dict[str, object]]] = []
62
+
63
+ class UnwaitedProcess:
64
+ def wait(self) -> None:
65
+ raise AssertionError("native startup must not wait")
66
+
67
+ def record_launch(
68
+ command: object,
69
+ **launch_options: object,
70
+ ) -> UnwaitedProcess:
71
+ all_launches.append((command, launch_options))
72
+ return UnwaitedProcess()
73
+
74
+ monkeypatch.setattr(subprocess, "Popen", record_launch)
75
+
76
+ start_automatic_advisory(build_context(tmp_path))
77
+
78
+ assert len(all_launches) == 1
79
+ _assert_launch_matches_runner(
80
+ all_launches[0],
81
+ python_path,
82
+ settings_path,
83
+ tmp_path,
84
+ )
85
+
86
+
87
+ def _assert_launch_matches_runner(
88
+ launch: tuple[object, dict[str, object]],
89
+ python_path: str,
90
+ settings_path: str,
91
+ repository_root: Path,
92
+ ) -> None:
93
+ command, launch_options = launch
94
+ assert command == [
95
+ python_path,
96
+ str(Path(__file__).resolve().parents[2] / "scripts" / "automatic_advisory" / "cli.py"),
97
+ "--settings",
98
+ settings_path,
99
+ "--start",
100
+ ]
101
+ assert launch_options["cwd"] == repository_root
102
+ assert launch_options["stdin"] is subprocess.DEVNULL
103
+ assert launch_options["stdout"] is subprocess.DEVNULL
104
+ assert launch_options["stderr"] is subprocess.DEVNULL
105
+ assert "--once" not in command
106
+
107
+
108
+ def test_start_automatic_advisory_ignores_malformed_configuration(
109
+ tmp_path: Path,
110
+ monkeypatch: pytest.MonkeyPatch,
111
+ ) -> None:
112
+ runner_path = tmp_path / ".git" / "local-verification" / "runner.json"
113
+ runner_path.parent.mkdir(parents=True)
114
+ runner_path.write_text("not-json", encoding="utf-8")
115
+ monkeypatch.setattr(
116
+ subprocess,
117
+ "Popen",
118
+ lambda *arguments, **options: pytest.fail("malformed configuration launched a process"),
119
+ )
120
+
121
+ start_automatic_advisory(build_context(tmp_path))
122
+
123
+
124
+ def test_start_automatic_advisory_ignores_other_repository_and_event(
125
+ tmp_path: Path,
126
+ monkeypatch: pytest.MonkeyPatch,
127
+ ) -> None:
128
+ write_runner_configuration(
129
+ tmp_path,
130
+ {"python": str(tmp_path / "python"), "settings": str(tmp_path / "settings")},
131
+ )
132
+ launch_count = 0
133
+
134
+ def count_launches(*arguments: object, **options: object) -> None:
135
+ nonlocal launch_count
136
+ launch_count += 1
137
+
138
+ monkeypatch.setattr(subprocess, "Popen", count_launches)
139
+
140
+ start_automatic_advisory(
141
+ build_context(tmp_path, repository_remote="other-owner/other-repository")
142
+ )
143
+ start_automatic_advisory(build_context(tmp_path, event="merge"))
144
+
145
+ assert launch_count == 0
146
+
147
+
148
+ def test_start_automatic_advisory_ignores_launch_failure(
149
+ tmp_path: Path,
150
+ monkeypatch: pytest.MonkeyPatch,
151
+ ) -> None:
152
+ write_runner_configuration(
153
+ tmp_path,
154
+ {"python": str(tmp_path / "python"), "settings": str(tmp_path / "settings")},
155
+ )
156
+
157
+ def fail_to_launch(*arguments: object, **options: object) -> None:
158
+ raise OSError("process unavailable")
159
+
160
+ monkeypatch.setattr(subprocess, "Popen", fail_to_launch)
161
+
162
+ start_automatic_advisory(build_context(tmp_path))
163
+
164
+
165
+ def test_notice_prints_runner_python_in_canonical_command(tmp_path: Path) -> None:
166
+ python_path, settings_path = create_runner_configuration(tmp_path)
167
+
168
+ notice_text = build_verification_notice(build_context(tmp_path))
169
+
170
+ expected_command = (
171
+ f"& '{python_path}' '{tmp_path / '.github' / 'ci' / 'local_verify.py'}' --base origin/main"
172
+ )
173
+ assert expected_command in notice_text
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """Print a bounded, nonblocking local verification reminder for native Git."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import Never, TextIO
11
+
12
+ from git_hooks_constants.verification_notice_constants import (
13
+ ALL_NOTICE_EVENTS,
14
+ BASE_ARGUMENT,
15
+ BASE_REFERENCE,
16
+ COMMAND_SEPARATOR,
17
+ EXECUTOR_ARGUMENT,
18
+ EXECUTOR_DIRECTORY_NAME,
19
+ EXECUTOR_FILE_NAME,
20
+ GIT_COMMAND_SUCCESS_EXIT_CODE,
21
+ GIT_DIRECTORY_NAME,
22
+ JSON_ENCODING,
23
+ LOCAL_VERIFICATION_DIRECTORY_NAME,
24
+ LOCAL_VERIFICATION_PACKAGE_DIRECTORY_NAME,
25
+ NO_VERIFIED_SHA,
26
+ NOTICE_ADVISORY_LINE,
27
+ NOTICE_HEADER,
28
+ NOTICE_LINE_SEPARATOR,
29
+ NOTICE_RUN_LINE,
30
+ OUTPUT_ARGUMENT,
31
+ PACKAGE_ROOT_PARENT_INDEX,
32
+ POWERSHELL_CALL_OPERATOR,
33
+ POWERSHELL_ESCAPED_QUOTE,
34
+ POWERSHELL_QUOTE,
35
+ PYTHON_COMMAND,
36
+ REPORT_FILE_NAME,
37
+ SCOPED_VERIFICATION_SCRIPT_PATH,
38
+ TARGET_REPOSITORY_REMOTE,
39
+ UNKNOWN_SHA,
40
+ )
41
+
42
+ verification_scripts_directory = (
43
+ Path(__file__).resolve().parents[PACKAGE_ROOT_PARENT_INDEX] / EXECUTOR_DIRECTORY_NAME
44
+ )
45
+ if str(verification_scripts_directory) not in sys.path:
46
+ sys.path.insert(0, str(verification_scripts_directory))
47
+
48
+ from verification_notice_context import (
49
+ VerificationNoticeContext,
50
+ _load_notice_context,
51
+ _run_git_query,
52
+ )
53
+ from verification_start import load_runner_configuration, start_automatic_advisory
54
+ from verification_notice_state import (
55
+ _build_state_message,
56
+ _evaluate_context,
57
+ )
58
+
59
+
60
+ class _NoticeArgumentParser(argparse.ArgumentParser):
61
+ def error(self, message: str) -> Never:
62
+ raise ValueError(message)
63
+
64
+
65
+ def build_verification_notice(context: VerificationNoticeContext) -> str:
66
+ """Render the advisory for one normalized repository context.
67
+
68
+ Args:
69
+ context: Repository fields that define the rendered notice.
70
+
71
+ Returns:
72
+ The formatted advisory text, or an empty string for another repository.
73
+ """
74
+ if context.repository_remote != TARGET_REPOSITORY_REMOTE:
75
+ return ""
76
+ current_head = context.current_head or UNKNOWN_SHA
77
+ state, verified_head = _evaluate_context(context, _run_git_query)
78
+ all_notice_lines = (
79
+ NOTICE_HEADER,
80
+ f"Repository remote: {context.repository_remote}",
81
+ f"Event: {context.event}",
82
+ f"Current SHA: {current_head}",
83
+ f"Verified SHA: {verified_head or NO_VERIFIED_SHA}",
84
+ f"State: {state}",
85
+ _build_state_message(state, context.manifest_is_available),
86
+ NOTICE_ADVISORY_LINE,
87
+ NOTICE_RUN_LINE,
88
+ _build_canonical_command(context),
89
+ )
90
+ return NOTICE_LINE_SEPARATOR.join(all_notice_lines) + NOTICE_LINE_SEPARATOR
91
+
92
+
93
+ def main(
94
+ all_arguments: Sequence[str] | None = None,
95
+ *,
96
+ stdout: TextIO = sys.stdout,
97
+ ) -> int:
98
+ """Read Git metadata and print a reminder without affecting the Git action.
99
+
100
+ Args:
101
+ all_arguments: Optional command arguments for the notice CLI.
102
+ stdout: Stream receiving the rendered notice.
103
+
104
+ Returns:
105
+ Zero so the native Git action continues.
106
+ """
107
+ parsed_arguments = _parse_arguments(all_arguments)
108
+ if parsed_arguments is None:
109
+ return GIT_COMMAND_SUCCESS_EXIT_CODE
110
+ try:
111
+ _write_parsed_notice(parsed_arguments, stdout)
112
+ except (OSError, UnicodeError, ValueError):
113
+ return GIT_COMMAND_SUCCESS_EXIT_CODE
114
+ return GIT_COMMAND_SUCCESS_EXIT_CODE
115
+
116
+
117
+ def _write_parsed_notice(parsed_arguments: argparse.Namespace, stdout: TextIO) -> None:
118
+ notice_context = _load_notice_context(
119
+ parsed_arguments.event,
120
+ Path(parsed_arguments.repository),
121
+ )
122
+ if notice_context is None:
123
+ return
124
+ _write_notice(stdout, build_verification_notice(notice_context))
125
+ if parsed_arguments.notice_only:
126
+ return
127
+ start_automatic_advisory(notice_context)
128
+
129
+
130
+ def _parse_arguments(
131
+ all_arguments: Sequence[str] | None,
132
+ ) -> argparse.Namespace | None:
133
+ argument_parser = _NoticeArgumentParser(add_help=True)
134
+ argument_parser.add_argument("--event", required=True)
135
+ argument_parser.add_argument("--notice-only", action="store_true")
136
+ argument_parser.add_argument("--repo", dest="repository", required=True)
137
+ try:
138
+ parsed_arguments, unknown_arguments = argument_parser.parse_known_args(all_arguments)
139
+ except (SystemExit, ValueError):
140
+ return None
141
+ if unknown_arguments or parsed_arguments.event not in ALL_NOTICE_EVENTS:
142
+ return None
143
+ return parsed_arguments
144
+
145
+
146
+ def _write_notice(stdout: TextIO, notice_text: str) -> None:
147
+ try:
148
+ stdout.write(notice_text)
149
+ except UnicodeError:
150
+ stream_encoding = getattr(stdout, "encoding", None) or JSON_ENCODING
151
+ safe_notice_text = notice_text.encode(
152
+ stream_encoding,
153
+ errors="replace",
154
+ ).decode(stream_encoding, errors="replace")
155
+ stdout.write(safe_notice_text)
156
+
157
+
158
+ def _build_canonical_command(context: VerificationNoticeContext) -> str:
159
+ git_directory = context.git_directory or context.repository_root / GIT_DIRECTORY_NAME
160
+ report_path = git_directory / LOCAL_VERIFICATION_DIRECTORY_NAME / REPORT_FILE_NAME
161
+ executor_path = (
162
+ Path(__file__).resolve().parents[PACKAGE_ROOT_PARENT_INDEX]
163
+ / EXECUTOR_DIRECTORY_NAME
164
+ / LOCAL_VERIFICATION_PACKAGE_DIRECTORY_NAME
165
+ / EXECUTOR_FILE_NAME
166
+ )
167
+ all_command_parts = (
168
+ _build_python_command(context),
169
+ _quote_windows_path(context.repository_root / SCOPED_VERIFICATION_SCRIPT_PATH),
170
+ f"{BASE_ARGUMENT} {BASE_REFERENCE}",
171
+ f"{EXECUTOR_ARGUMENT} {_quote_windows_path(executor_path)}",
172
+ f"{OUTPUT_ARGUMENT} {_quote_windows_path(report_path)}",
173
+ )
174
+ return COMMAND_SEPARATOR.join(all_command_parts)
175
+
176
+
177
+ def _build_python_command(context: VerificationNoticeContext) -> str:
178
+ runner_configuration = load_runner_configuration(context)
179
+ if runner_configuration is None:
180
+ return PYTHON_COMMAND
181
+ python_path, _ = runner_configuration
182
+ return POWERSHELL_CALL_OPERATOR + _quote_windows_path(Path(python_path))
183
+
184
+
185
+ def _quote_windows_path(path: Path) -> str:
186
+ path_text = str(path)
187
+ escaped_path = path_text.replace(POWERSHELL_QUOTE, POWERSHELL_ESCAPED_QUOTE)
188
+ return f"{POWERSHELL_QUOTE}{escaped_path}{POWERSHELL_QUOTE}"
189
+
190
+
191
+ if __name__ == "__main__":
192
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,253 @@
1
+ """Load repository context used by the native Git verification notice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from urllib.parse import urlsplit
11
+
12
+ from git_hooks_constants import (
13
+ GIT_COMMAND_TIMEOUT_SECONDS,
14
+ GIT_EXECUTABLE_NAME,
15
+ GIT_OUTPUT_DECODE_ERRORS_POLICY,
16
+ GIT_OUTPUT_ENCODING_NAME,
17
+ )
18
+ from git_hooks_constants.verification_notice_constants import (
19
+ ALL_GIT_ABSOLUTE_DIRECTORY_QUERY,
20
+ ALL_GIT_HEAD_QUERY,
21
+ ALL_GIT_REMOTE_URL_QUERY,
22
+ ALL_GIT_REPOSITORY_ROOT_QUERY,
23
+ GIT_COMMAND_SUCCESS_EXIT_CODE,
24
+ GIT_DIRECTORY_NAME,
25
+ JSON_ENCODING,
26
+ LOCAL_VERIFICATION_DIRECTORY_NAME,
27
+ MANIFEST_DIRECTORY_NAME,
28
+ MANIFEST_FILE_NAME,
29
+ MINIMUM_REMOTE_PART_COUNT,
30
+ OWNER_PART_INDEX,
31
+ REPORT_FILE_NAME,
32
+ REPOSITORY_PART_INDEX,
33
+ SELECTED_MANIFEST_PATH_FIELD,
34
+ SELECTION_FIELD,
35
+ TARGET_REPOSITORY_REMOTE,
36
+ )
37
+ from local_verification.manifest import ManifestRunFatal, load_manifest
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class VerificationNoticeContext:
42
+ event: str
43
+ repository_remote: str
44
+ repository_root: Path
45
+ current_head: str | None
46
+ manifest_path: Path | None
47
+ manifest_is_available: bool
48
+ git_directory: Path | None
49
+ report_is_present: bool
50
+ all_report_fields: Mapping[str, object] | None
51
+
52
+
53
+ def normalize_repository_remote(remote_url: str) -> str | None:
54
+ """Return an owner/repository slug from common Git remote URL forms.
55
+
56
+ Args:
57
+ remote_url: Git remote URL to normalize.
58
+
59
+ Returns:
60
+ The normalized owner/repository slug, or None for an invalid URL.
61
+ """
62
+ stripped_remote_url = remote_url.strip().rstrip("/")
63
+ if not stripped_remote_url:
64
+ return None
65
+ remote_path = _extract_remote_path(stripped_remote_url)
66
+ all_remote_parts = [each_part for each_part in remote_path.split("/") if each_part]
67
+ if len(all_remote_parts) < MINIMUM_REMOTE_PART_COUNT:
68
+ return None
69
+ repository_name = all_remote_parts[REPOSITORY_PART_INDEX].removesuffix(GIT_DIRECTORY_NAME)
70
+ owner_name = all_remote_parts[OWNER_PART_INDEX]
71
+ if not owner_name or not repository_name:
72
+ return None
73
+ return f"{owner_name}/{repository_name}".casefold()
74
+
75
+
76
+ def _load_notice_context(
77
+ event: str, repository_path: Path
78
+ ) -> VerificationNoticeContext | None:
79
+ repository_identity = _load_repository_identity(repository_path)
80
+ if repository_identity is None:
81
+ return None
82
+ repository_root, current_head, git_directory_text = repository_identity
83
+ normalized_remote = normalize_repository_remote(
84
+ _run_git_query(repository_root, ALL_GIT_REMOTE_URL_QUERY, False) or ""
85
+ )
86
+ if normalized_remote != TARGET_REPOSITORY_REMOTE or current_head is None:
87
+ return None
88
+ if not git_directory_text:
89
+ return None
90
+ git_directory = _resolve_common_directory(repository_root, git_directory_text)
91
+ all_report_fields, report_is_present = _load_repository_report(git_directory)
92
+ manifest_path, manifest_is_available = _load_manifest_context(
93
+ repository_root, git_directory, all_report_fields
94
+ )
95
+ return VerificationNoticeContext(
96
+ event=event,
97
+ repository_remote=normalized_remote,
98
+ repository_root=repository_root,
99
+ current_head=current_head,
100
+ manifest_path=manifest_path,
101
+ manifest_is_available=manifest_is_available,
102
+ git_directory=git_directory,
103
+ report_is_present=report_is_present,
104
+ all_report_fields=all_report_fields,
105
+ )
106
+
107
+
108
+ def _load_manifest_context(
109
+ repository_root: Path,
110
+ git_directory: Path,
111
+ all_report_fields: Mapping[str, object] | None,
112
+ ) -> tuple[Path | None, bool]:
113
+ manifest_path = _resolve_selected_manifest_path(
114
+ repository_root, git_directory, all_report_fields
115
+ )
116
+ return manifest_path, _manifest_is_available(manifest_path)
117
+
118
+
119
+ def _load_repository_report(
120
+ git_directory: Path,
121
+ ) -> tuple[Mapping[str, object] | None, bool]:
122
+ report_path = git_directory / LOCAL_VERIFICATION_DIRECTORY_NAME / REPORT_FILE_NAME
123
+ return _load_json_mapping(report_path), report_path.is_file()
124
+
125
+
126
+ def _resolve_selected_manifest_path(
127
+ repository_root: Path,
128
+ git_directory: Path,
129
+ all_report_fields: Mapping[str, object] | None,
130
+ ) -> Path | None:
131
+ selected_path_text = _read_selected_manifest_path(all_report_fields)
132
+ if selected_path_text is None:
133
+ if all_report_fields is not None:
134
+ return None
135
+ return repository_root / MANIFEST_DIRECTORY_NAME / MANIFEST_FILE_NAME
136
+ selected_path = Path(selected_path_text)
137
+ if not selected_path.is_absolute():
138
+ selected_path = repository_root / selected_path
139
+ resolved_path = selected_path.resolve()
140
+ if _is_inside_repository(resolved_path, repository_root):
141
+ return resolved_path
142
+ metadata_directory = git_directory / LOCAL_VERIFICATION_DIRECTORY_NAME
143
+ if _is_inside_repository(resolved_path, metadata_directory):
144
+ return resolved_path
145
+ return None
146
+
147
+
148
+ def _read_selected_manifest_path(
149
+ all_report_fields: Mapping[str, object] | None,
150
+ ) -> str | None:
151
+ if all_report_fields is None:
152
+ return None
153
+ selection_record = all_report_fields.get(SELECTION_FIELD)
154
+ if not isinstance(selection_record, Mapping):
155
+ return None
156
+ selected_path_text = selection_record.get(SELECTED_MANIFEST_PATH_FIELD)
157
+ if not isinstance(selected_path_text, str) or not selected_path_text.strip():
158
+ return None
159
+ return selected_path_text
160
+
161
+
162
+ def _is_inside_repository(candidate_path: Path, repository_root: Path) -> bool:
163
+ try:
164
+ candidate_path.relative_to(repository_root)
165
+ except ValueError:
166
+ return False
167
+ return True
168
+
169
+
170
+ def _load_repository_identity(
171
+ repository_path: Path,
172
+ ) -> tuple[Path, str | None, str | None] | None:
173
+ if not repository_path.is_dir():
174
+ return None
175
+ repository_root_text = _run_git_query(
176
+ repository_path,
177
+ ALL_GIT_REPOSITORY_ROOT_QUERY,
178
+ False,
179
+ )
180
+ if repository_root_text is None:
181
+ return None
182
+ repository_root = Path(repository_root_text).resolve()
183
+ return (
184
+ repository_root,
185
+ _run_git_query(repository_root, ALL_GIT_HEAD_QUERY, False),
186
+ _run_git_query(repository_root, ALL_GIT_ABSOLUTE_DIRECTORY_QUERY, False),
187
+ )
188
+
189
+
190
+ def _run_git_query(
191
+ repository_root: Path,
192
+ all_git_arguments: tuple[str, ...],
193
+ should_allow_empty: bool,
194
+ ) -> str | None:
195
+ try:
196
+ completed_process = subprocess.run(
197
+ [GIT_EXECUTABLE_NAME, *all_git_arguments],
198
+ cwd=repository_root,
199
+ check=False,
200
+ capture_output=True,
201
+ text=True,
202
+ encoding=GIT_OUTPUT_ENCODING_NAME,
203
+ errors=GIT_OUTPUT_DECODE_ERRORS_POLICY,
204
+ timeout=GIT_COMMAND_TIMEOUT_SECONDS,
205
+ )
206
+ except (OSError, subprocess.SubprocessError, UnicodeError):
207
+ return None
208
+ if completed_process.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
209
+ return None
210
+ stripped_stdout = completed_process.stdout.strip()
211
+ if stripped_stdout or should_allow_empty:
212
+ return stripped_stdout
213
+ return None
214
+
215
+
216
+ def _extract_remote_path(remote_url: str) -> str:
217
+ if "://" in remote_url:
218
+ return urlsplit(remote_url).path
219
+ if "@" in remote_url and ":" in remote_url:
220
+ return remote_url.rsplit(":", 1)[1]
221
+ return remote_url
222
+
223
+
224
+ def _resolve_common_directory(
225
+ repository_root: Path,
226
+ common_directory_text: str,
227
+ ) -> Path:
228
+ common_directory_path = Path(common_directory_text)
229
+ if common_directory_path.is_absolute():
230
+ return common_directory_path.resolve()
231
+ return (repository_root / common_directory_path).resolve()
232
+
233
+
234
+ def _load_json_mapping(json_path: Path) -> Mapping[str, object] | None:
235
+ if not json_path.is_file():
236
+ return None
237
+ try:
238
+ parsed_json: object = json.loads(json_path.read_text(encoding=JSON_ENCODING))
239
+ except (OSError, UnicodeError, ValueError):
240
+ return None
241
+ if not isinstance(parsed_json, Mapping):
242
+ return None
243
+ return parsed_json
244
+
245
+
246
+ def _manifest_is_available(manifest_path: Path | None) -> bool:
247
+ if manifest_path is None:
248
+ return False
249
+ try:
250
+ load_manifest(manifest_path)
251
+ except (ManifestRunFatal, OSError, UnicodeError, ValueError):
252
+ return False
253
+ return True