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.
- package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +78 -0
- package/bin/install-session-continuity.test.mjs +42 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
TESTS_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
if str(TESTS_DIRECTORY) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(TESTS_DIRECTORY))
|
|
17
|
+
|
|
18
|
+
import test_automatic_advisory as support
|
|
19
|
+
from automatic_advisory import cli, git, publisher
|
|
20
|
+
from pr_verification.model import RepositorySettings, StatusState
|
|
21
|
+
|
|
22
|
+
PROCESS_RUNNER_TIMEOUT_SECONDS = 10.0
|
|
23
|
+
PRODUCTION_CHILD_TIMEOUT_SECONDS = 3.0
|
|
24
|
+
PROCESS_EXIT_WAIT_SECONDS = 2.0
|
|
25
|
+
PROCESS_POLL_SECONDS = 0.05
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _SuccessfulPublicationAdapter:
|
|
29
|
+
status = StatusState.SUCCESS
|
|
30
|
+
description = "passed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _publisher_module_recording_paths(
|
|
34
|
+
all_published_manifest_paths: list[Path],
|
|
35
|
+
) -> SimpleNamespace:
|
|
36
|
+
def record_publication(
|
|
37
|
+
_github: object,
|
|
38
|
+
_repository: RepositorySettings,
|
|
39
|
+
_pull_request_number: int,
|
|
40
|
+
_checkout_path: Path,
|
|
41
|
+
manifest_path: Path,
|
|
42
|
+
_report_path: Path,
|
|
43
|
+
) -> _SuccessfulPublicationAdapter:
|
|
44
|
+
all_published_manifest_paths.append(manifest_path)
|
|
45
|
+
return _SuccessfulPublicationAdapter()
|
|
46
|
+
|
|
47
|
+
return SimpleNamespace(publish_local_report=record_publication)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_counted_runner(
|
|
51
|
+
registration: support.AdvisoryRegistration,
|
|
52
|
+
github: support.FakeGitHub,
|
|
53
|
+
child_calls: list[str],
|
|
54
|
+
) -> support.AutomaticAdvisoryRunner:
|
|
55
|
+
def run_child(
|
|
56
|
+
child_registration: support.AdvisoryRegistration,
|
|
57
|
+
base_ref: str,
|
|
58
|
+
timeout_seconds: float,
|
|
59
|
+
) -> support.ChildOutcome:
|
|
60
|
+
return support._run_counted_child(
|
|
61
|
+
child_calls,
|
|
62
|
+
child_registration,
|
|
63
|
+
base_ref,
|
|
64
|
+
timeout_seconds,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return support.AutomaticAdvisoryRunner(
|
|
68
|
+
support._build_settings(registration),
|
|
69
|
+
github,
|
|
70
|
+
child_runner=run_child,
|
|
71
|
+
clock=lambda: "2026-09-05T00:00:00+00:00",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_recovering_cli_runner(
|
|
76
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
77
|
+
registration: support.AdvisoryRegistration,
|
|
78
|
+
github: support.FakeGitHub,
|
|
79
|
+
authentication_attempts: list[str],
|
|
80
|
+
child_calls: list[str],
|
|
81
|
+
) -> support.AutomaticAdvisoryRunner:
|
|
82
|
+
def issue_repository_api(*arguments: object, **options: object) -> object:
|
|
83
|
+
authentication_attempts.append("attempt")
|
|
84
|
+
if len(authentication_attempts) == 1:
|
|
85
|
+
raise RuntimeError("offline")
|
|
86
|
+
return github
|
|
87
|
+
|
|
88
|
+
monkeypatch.setattr(cli, "_issue_repository_api", issue_repository_api)
|
|
89
|
+
runner = cli._build_runner(support._build_settings(registration))
|
|
90
|
+
counted_runner = _build_counted_runner(registration, github, child_calls)
|
|
91
|
+
runner.child_runner = counted_runner.child_runner
|
|
92
|
+
runner.publisher = support._publish_passed
|
|
93
|
+
return runner
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_report_only_child(
|
|
97
|
+
registration: support.AdvisoryRegistration,
|
|
98
|
+
) -> support.ChildOutcome:
|
|
99
|
+
registration.report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
registration.report_path.write_text(
|
|
101
|
+
json.dumps({"exit_code": 0}),
|
|
102
|
+
encoding="utf-8",
|
|
103
|
+
)
|
|
104
|
+
return support.ChildOutcome(0, "", "", False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _record_unexpected_publication(
|
|
108
|
+
publisher_calls: list[str],
|
|
109
|
+
*all_arguments: object,
|
|
110
|
+
) -> support.Publication:
|
|
111
|
+
publisher_calls.append("publish")
|
|
112
|
+
return support.FakePublication("passed")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_publisher_adapter_returns_github_status_text(
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
117
|
+
) -> None:
|
|
118
|
+
class PublicationAdapter:
|
|
119
|
+
status = StatusState.SUCCESS
|
|
120
|
+
description = "passed"
|
|
121
|
+
|
|
122
|
+
class PublisherModule:
|
|
123
|
+
publish_local_report = staticmethod(lambda *arguments: PublicationAdapter())
|
|
124
|
+
|
|
125
|
+
monkeypatch.setattr(publisher, "import_module", lambda name: PublisherModule)
|
|
126
|
+
registration = support._build_registration(tmp_path, tmp_path)
|
|
127
|
+
publication = publisher._publish_report(
|
|
128
|
+
object(),
|
|
129
|
+
registration.repository,
|
|
130
|
+
registration.pull_request_number,
|
|
131
|
+
registration.checkout_path,
|
|
132
|
+
registration.manifest_absolute_path,
|
|
133
|
+
registration.report_path,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert publication.status == "success"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_real_publisher_adapter_runs_once_for_unchanged_identity(
|
|
140
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
141
|
+
tmp_path: Path,
|
|
142
|
+
) -> None:
|
|
143
|
+
all_published_manifest_paths: list[Path] = []
|
|
144
|
+
publisher_module = _publisher_module_recording_paths(all_published_manifest_paths)
|
|
145
|
+
monkeypatch.setattr(publisher, "import_module", lambda name: publisher_module)
|
|
146
|
+
checkout_path, head_sha = support._build_checkout(tmp_path)
|
|
147
|
+
registration = support._build_registration(tmp_path, checkout_path)
|
|
148
|
+
github = support.FakeGitHub(support._build_candidate(head_sha))
|
|
149
|
+
child_calls: list[str] = []
|
|
150
|
+
runner = _build_counted_runner(registration, github, child_calls)
|
|
151
|
+
first_state = runner.run_once()[0]
|
|
152
|
+
second_state = runner.run_once()[0]
|
|
153
|
+
|
|
154
|
+
assert first_state.status == "passed"
|
|
155
|
+
assert second_state.status == "passed"
|
|
156
|
+
assert child_calls == ["child"]
|
|
157
|
+
assert all_published_manifest_paths == [registration.selected_manifest_path]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_build_runner_run_once_persists_offline_then_recovers(
|
|
161
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
162
|
+
) -> None:
|
|
163
|
+
checkout_path, head_sha = support._build_checkout(tmp_path)
|
|
164
|
+
registration = support._build_registration(tmp_path, checkout_path)
|
|
165
|
+
github = support.FakeGitHub(support._build_candidate(head_sha))
|
|
166
|
+
authentication_attempts: list[str] = []
|
|
167
|
+
child_calls: list[str] = []
|
|
168
|
+
runner = _build_recovering_cli_runner(
|
|
169
|
+
monkeypatch,
|
|
170
|
+
registration,
|
|
171
|
+
github,
|
|
172
|
+
authentication_attempts,
|
|
173
|
+
child_calls,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
assert authentication_attempts == []
|
|
177
|
+
offline_state = runner.run_once()[0]
|
|
178
|
+
persisted_offline_state = json.loads(
|
|
179
|
+
registration.state_path.read_text(encoding="utf-8")
|
|
180
|
+
)
|
|
181
|
+
passed_state = runner.run_once()[0]
|
|
182
|
+
|
|
183
|
+
assert offline_state.status == "offline"
|
|
184
|
+
assert persisted_offline_state["status"] == "offline"
|
|
185
|
+
assert passed_state.status == "passed"
|
|
186
|
+
assert authentication_attempts == ["attempt", "attempt", "attempt"]
|
|
187
|
+
assert child_calls == ["child"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_runner_marks_missing_selected_manifest_incomplete(tmp_path: Path) -> None:
|
|
191
|
+
checkout_path, head_sha = support._build_checkout(tmp_path)
|
|
192
|
+
registration = support._build_registration(tmp_path, checkout_path)
|
|
193
|
+
github = support.FakeGitHub(support._build_candidate(head_sha))
|
|
194
|
+
publisher_calls: list[str] = []
|
|
195
|
+
|
|
196
|
+
runner = support.AutomaticAdvisoryRunner(
|
|
197
|
+
support._build_settings(registration),
|
|
198
|
+
github,
|
|
199
|
+
child_runner=lambda child_registration, _base_ref, _timeout_seconds: (
|
|
200
|
+
_run_report_only_child(child_registration)
|
|
201
|
+
),
|
|
202
|
+
publisher=lambda *all_arguments: _record_unexpected_publication(
|
|
203
|
+
publisher_calls,
|
|
204
|
+
*all_arguments,
|
|
205
|
+
),
|
|
206
|
+
clock=lambda: "2026-09-05T00:00:00+00:00",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
state = runner.run_once()[0]
|
|
210
|
+
|
|
211
|
+
assert state.status == "error"
|
|
212
|
+
assert state.reason == "verification child did not produce a report"
|
|
213
|
+
assert publisher_calls == []
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_fetch_base_returns_false_after_git_timeout(
|
|
217
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
218
|
+
) -> None:
|
|
219
|
+
registration = support._build_registration(tmp_path, tmp_path)
|
|
220
|
+
candidate = support._build_candidate("head")
|
|
221
|
+
captured_options: dict[str, object] = {}
|
|
222
|
+
|
|
223
|
+
def timeout_run(*arguments: object, **options: object) -> object:
|
|
224
|
+
captured_options.update(options)
|
|
225
|
+
raise subprocess.TimeoutExpired("git", 30.0)
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(git.subprocess, "run", timeout_run)
|
|
228
|
+
|
|
229
|
+
assert git._fetch_base(registration, candidate) is False
|
|
230
|
+
assert captured_options["timeout"] == 30.0
|
|
231
|
+
captured_environment = captured_options["env"]
|
|
232
|
+
assert isinstance(captured_environment, dict)
|
|
233
|
+
assert captured_environment.get("GIT_TERMINAL_PROMPT") == "0"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_fetch_base_updates_dedicated_and_remote_tracking_refs(
|
|
237
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
238
|
+
tmp_path: Path,
|
|
239
|
+
) -> None:
|
|
240
|
+
registration = support._build_registration(tmp_path, tmp_path)
|
|
241
|
+
candidate = support._build_candidate("head")
|
|
242
|
+
all_fetch_arguments: list[tuple[str, ...]] = []
|
|
243
|
+
|
|
244
|
+
def record_fetch(
|
|
245
|
+
fetch_arguments: tuple[str, ...],
|
|
246
|
+
**options: object,
|
|
247
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
248
|
+
all_fetch_arguments.append(fetch_arguments)
|
|
249
|
+
return subprocess.CompletedProcess(fetch_arguments, 0, b"", b"")
|
|
250
|
+
|
|
251
|
+
monkeypatch.setattr(git.subprocess, "run", record_fetch)
|
|
252
|
+
|
|
253
|
+
assert git._fetch_base(registration, candidate) is True
|
|
254
|
+
assert all_fetch_arguments == [
|
|
255
|
+
(
|
|
256
|
+
"git",
|
|
257
|
+
"fetch",
|
|
258
|
+
"--force",
|
|
259
|
+
"--no-tags",
|
|
260
|
+
"origin",
|
|
261
|
+
"+refs/heads/main:refs/cde/advisory-base/2985",
|
|
262
|
+
"+refs/heads/main:refs/remotes/origin/main",
|
|
263
|
+
)
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _build_descendant_parent_script(
|
|
268
|
+
descendant_identifier_path: Path,
|
|
269
|
+
parent_action: str,
|
|
270
|
+
) -> str:
|
|
271
|
+
descendant_script = "import time; time.sleep(30)"
|
|
272
|
+
return (
|
|
273
|
+
"import subprocess,sys,time\n"
|
|
274
|
+
"from pathlib import Path\n"
|
|
275
|
+
f"child_process = subprocess.Popen((sys.executable, '-c', {descendant_script!r}))\n"
|
|
276
|
+
f"Path({str(descendant_identifier_path)!r}).write_text(str(child_process.pid), encoding='utf-8')\n"
|
|
277
|
+
f"{parent_action}\n"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _build_process_runner_script(
|
|
282
|
+
checkout_path: Path,
|
|
283
|
+
descendant_identifier_path: Path,
|
|
284
|
+
parent_action: str,
|
|
285
|
+
) -> str:
|
|
286
|
+
parent_script = _build_descendant_parent_script(
|
|
287
|
+
descendant_identifier_path,
|
|
288
|
+
parent_action,
|
|
289
|
+
)
|
|
290
|
+
return (
|
|
291
|
+
"import os,sys\n"
|
|
292
|
+
"from pathlib import Path\n"
|
|
293
|
+
"from types import SimpleNamespace\n"
|
|
294
|
+
"from automatic_advisory import execution\n"
|
|
295
|
+
f"registration = SimpleNamespace(checkout_path=Path({str(checkout_path)!r}))\n"
|
|
296
|
+
"scripts_directory = Path(execution.__file__).resolve().parents[1]\n"
|
|
297
|
+
"child_environment = execution._build_child_environment(registration, scripts_directory)\n"
|
|
298
|
+
"child_process = execution._start_child_process(\n"
|
|
299
|
+
f" (sys.executable, '-c', {parent_script!r}), registration, child_environment\n"
|
|
300
|
+
")\n"
|
|
301
|
+
"if child_process is None:\n"
|
|
302
|
+
" raise SystemExit(2)\n"
|
|
303
|
+
f"child_outcome = execution._collect_child_outcome(child_process, {PRODUCTION_CHILD_TIMEOUT_SECONDS!r})\n"
|
|
304
|
+
"if not child_outcome.timed_out:\n"
|
|
305
|
+
" raise SystemExit(3)\n"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_process_running(process_identifier: int) -> bool:
|
|
310
|
+
if sys.platform == "win32":
|
|
311
|
+
completed_process = subprocess.run(
|
|
312
|
+
("tasklist", "/FI", f"PID eq {process_identifier}", "/FO", "CSV", "/NH"),
|
|
313
|
+
capture_output=True,
|
|
314
|
+
check=False,
|
|
315
|
+
text=True,
|
|
316
|
+
timeout=PROCESS_EXIT_WAIT_SECONDS,
|
|
317
|
+
)
|
|
318
|
+
return f'"{process_identifier}"' in completed_process.stdout
|
|
319
|
+
try:
|
|
320
|
+
os.kill(process_identifier, 0)
|
|
321
|
+
except ProcessLookupError:
|
|
322
|
+
return False
|
|
323
|
+
except PermissionError:
|
|
324
|
+
return True
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _wait_for_process_exit(process_identifier: int) -> bool:
|
|
329
|
+
deadline = time.monotonic() + PROCESS_EXIT_WAIT_SECONDS
|
|
330
|
+
while time.monotonic() < deadline:
|
|
331
|
+
if not _is_process_running(process_identifier):
|
|
332
|
+
return True
|
|
333
|
+
time.sleep(PROCESS_POLL_SECONDS)
|
|
334
|
+
return not _is_process_running(process_identifier)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _end_process_for_test(process_identifier: int) -> None:
|
|
338
|
+
if not _is_process_running(process_identifier):
|
|
339
|
+
return
|
|
340
|
+
if sys.platform == "win32":
|
|
341
|
+
subprocess.run(
|
|
342
|
+
("taskkill", "/T", "/F", "/PID", str(process_identifier)),
|
|
343
|
+
capture_output=True,
|
|
344
|
+
check=False,
|
|
345
|
+
timeout=PROCESS_EXIT_WAIT_SECONDS,
|
|
346
|
+
)
|
|
347
|
+
return
|
|
348
|
+
os.kill(process_identifier, signal.SIGKILL)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@pytest.mark.parametrize(
|
|
352
|
+
"parent_action",
|
|
353
|
+
("time.sleep(30)", "raise SystemExit(0)"),
|
|
354
|
+
ids=("timed-parent", "exited-parent"),
|
|
355
|
+
)
|
|
356
|
+
def test_production_launch_timeout_ends_descendant_and_keeps_runner_alive(
|
|
357
|
+
tmp_path: Path,
|
|
358
|
+
parent_action: str,
|
|
359
|
+
) -> None:
|
|
360
|
+
descendant_identifier_path = tmp_path / "descendant.pid"
|
|
361
|
+
runner_script = _build_process_runner_script(
|
|
362
|
+
tmp_path,
|
|
363
|
+
descendant_identifier_path,
|
|
364
|
+
parent_action,
|
|
365
|
+
)
|
|
366
|
+
completed_runner = subprocess.run(
|
|
367
|
+
(sys.executable, "-c", runner_script),
|
|
368
|
+
cwd=support.SCRIPTS_DIRECTORY,
|
|
369
|
+
capture_output=True,
|
|
370
|
+
check=False,
|
|
371
|
+
text=True,
|
|
372
|
+
timeout=PROCESS_RUNNER_TIMEOUT_SECONDS,
|
|
373
|
+
)
|
|
374
|
+
assert descendant_identifier_path.is_file(), completed_runner.stderr
|
|
375
|
+
descendant_identifier = int(descendant_identifier_path.read_text(encoding="utf-8"))
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
assert completed_runner.returncode == 0, completed_runner.stderr
|
|
379
|
+
assert _wait_for_process_exit(descendant_identifier)
|
|
380
|
+
finally:
|
|
381
|
+
_end_process_for_test(descendant_identifier)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_detached_start_resolves_relative_settings_before_spawn(
|
|
385
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
386
|
+
) -> None:
|
|
387
|
+
all_launches: list[tuple[tuple[str, ...], dict[str, object]]] = []
|
|
388
|
+
monkeypatch.chdir(tmp_path)
|
|
389
|
+
monkeypatch.setattr(
|
|
390
|
+
cli.subprocess,
|
|
391
|
+
"Popen",
|
|
392
|
+
lambda arguments, **options: all_launches.append((tuple(arguments), options)),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
assert cli.start_polling(Path("settings.json")) == 0
|
|
396
|
+
|
|
397
|
+
launch_arguments = all_launches[0][0]
|
|
398
|
+
assert launch_arguments[launch_arguments.index("--settings") + 1] == str(
|
|
399
|
+
tmp_path / "settings.json"
|
|
400
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
TEST_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
if str(TEST_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(TEST_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
import test_automatic_advisory as support
|
|
14
|
+
from automatic_advisory import configuration as advisory_configuration
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize("field_name", ["poll_seconds", "child_timeout_seconds"])
|
|
18
|
+
@pytest.mark.parametrize("invalid_seconds", [float("nan"), float("inf"), float("-inf")])
|
|
19
|
+
def test_configuration_rejects_nonfinite_timing(
|
|
20
|
+
tmp_path: Path, field_name: str, invalid_seconds: float
|
|
21
|
+
) -> None:
|
|
22
|
+
settings_path = tmp_path / "settings.json"
|
|
23
|
+
all_settings = support._valid_advisory_settings_fields(tmp_path)
|
|
24
|
+
settings_path.write_text(json.dumps(all_settings), encoding="utf-8")
|
|
25
|
+
valid_settings = advisory_configuration.load_advisory_settings(settings_path)
|
|
26
|
+
assert valid_settings.poll_seconds == 0.25
|
|
27
|
+
assert valid_settings.child_timeout_seconds == 45.5
|
|
28
|
+
all_settings[field_name] = invalid_seconds
|
|
29
|
+
settings_path.write_text(json.dumps(all_settings), encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
with pytest.raises(
|
|
32
|
+
advisory_configuration.AdvisoryConfigurationError,
|
|
33
|
+
match=f"{field_name} must be positive",
|
|
34
|
+
):
|
|
35
|
+
advisory_configuration.load_advisory_settings(settings_path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_poll_error_log_sits_beside_the_registration_state_file(tmp_path: Path) -> None:
|
|
39
|
+
checkout_path = tmp_path / "checkout"
|
|
40
|
+
checkout_path.mkdir()
|
|
41
|
+
registration = support._build_registration(tmp_path, checkout_path)
|
|
42
|
+
settings = support._build_settings(registration)
|
|
43
|
+
|
|
44
|
+
assert (
|
|
45
|
+
settings.poll_error_log_path
|
|
46
|
+
== registration.state_path.parent / "poll-errors.log"
|
|
47
|
+
)
|
|
48
|
+
assert settings.poll_error_log_path.parent == settings.poll_lock_root.parent
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The advisory poller and every process it starts stay off the screen on Windows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
TESTS_DIRECTORY = Path(__file__).resolve().parent
|
|
14
|
+
if str(TESTS_DIRECTORY) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(TESTS_DIRECTORY))
|
|
16
|
+
|
|
17
|
+
import test_automatic_advisory as support
|
|
18
|
+
from automatic_advisory import checkout, cli, execution, git, window_flags
|
|
19
|
+
|
|
20
|
+
WINDOWS_ONLY = pytest.mark.skipif(
|
|
21
|
+
sys.platform != "win32", reason="Windows console flags"
|
|
22
|
+
)
|
|
23
|
+
PROBE_WAIT_SECONDS = 10.0
|
|
24
|
+
PROBE_POLL_SECONDS = 0.1
|
|
25
|
+
|
|
26
|
+
CHILD_REPORT_SCRIPT = (
|
|
27
|
+
"import ctypes, json, sys;"
|
|
28
|
+
"handle = ctypes.windll.kernel32.GetConsoleWindow();"
|
|
29
|
+
"visible = bool(ctypes.windll.user32.IsWindowVisible(handle)) if handle else False;"
|
|
30
|
+
"sys.stdout.write(json.dumps({'visible': visible}))"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
PARENT_PROBE_SCRIPT = """
|
|
34
|
+
import json, subprocess, sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
completed = subprocess.run(
|
|
37
|
+
[sys.executable, "-c", {child_script!r}], capture_output=True, text=True, check=False
|
|
38
|
+
)
|
|
39
|
+
Path(sys.argv[1]).write_text(completed.stdout, encoding="utf-8")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_flags_are_zero_off_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
44
|
+
monkeypatch.setattr(window_flags.sys, "platform", "linux")
|
|
45
|
+
assert window_flags.hidden_window_creation_flags() == 0
|
|
46
|
+
assert window_flags.detached_poller_creation_flags() == 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@WINDOWS_ONLY
|
|
50
|
+
def test_hidden_window_creation_flags_is_create_no_window() -> None:
|
|
51
|
+
assert window_flags.hidden_window_creation_flags() == subprocess.CREATE_NO_WINDOW
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@WINDOWS_ONLY
|
|
55
|
+
def test_poller_flags_keep_a_hidden_console_instead_of_none() -> None:
|
|
56
|
+
poller_flags = window_flags.detached_poller_creation_flags()
|
|
57
|
+
assert poller_flags & subprocess.DETACHED_PROCESS == 0
|
|
58
|
+
assert poller_flags & subprocess.CREATE_NO_WINDOW
|
|
59
|
+
assert poller_flags & subprocess.CREATE_NEW_PROCESS_GROUP
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@WINDOWS_ONLY
|
|
63
|
+
def test_poller_grandchild_gets_no_visible_console(tmp_path: Path) -> None:
|
|
64
|
+
parent_script_path = tmp_path / "parent_probe.py"
|
|
65
|
+
parent_script_path.write_text(
|
|
66
|
+
PARENT_PROBE_SCRIPT.format(child_script=CHILD_REPORT_SCRIPT), encoding="utf-8"
|
|
67
|
+
)
|
|
68
|
+
report_path = tmp_path / "grandchild.json"
|
|
69
|
+
subprocess.Popen(
|
|
70
|
+
[sys.executable, str(parent_script_path), str(report_path)],
|
|
71
|
+
stdin=subprocess.DEVNULL,
|
|
72
|
+
stdout=subprocess.DEVNULL,
|
|
73
|
+
stderr=subprocess.DEVNULL,
|
|
74
|
+
creationflags=window_flags.detached_poller_creation_flags(),
|
|
75
|
+
)
|
|
76
|
+
assert _read_probe_report(report_path) == {"visible": False}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_probe_report(report_path: Path) -> dict[str, bool]:
|
|
80
|
+
deadline = time.monotonic() + PROBE_WAIT_SECONDS
|
|
81
|
+
while time.monotonic() < deadline:
|
|
82
|
+
if report_path.exists():
|
|
83
|
+
return json.loads(report_path.read_text(encoding="utf-8"))
|
|
84
|
+
time.sleep(PROBE_POLL_SECONDS)
|
|
85
|
+
raise AssertionError("the probe parent never wrote its report")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_git_fetch_hides_its_window(
|
|
89
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
90
|
+
) -> None:
|
|
91
|
+
registration = support._build_registration(tmp_path, tmp_path)
|
|
92
|
+
candidate = support._build_candidate("head")
|
|
93
|
+
captured_options: dict[str, object] = {}
|
|
94
|
+
|
|
95
|
+
def record_run(
|
|
96
|
+
*arguments: object, **options: object
|
|
97
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
98
|
+
captured_options.update(options)
|
|
99
|
+
return subprocess.CompletedProcess(arguments, 0, b"", b"")
|
|
100
|
+
|
|
101
|
+
monkeypatch.setattr(git.subprocess, "run", record_run)
|
|
102
|
+
|
|
103
|
+
assert git._fetch_base(registration, candidate) is True
|
|
104
|
+
assert (
|
|
105
|
+
captured_options["creationflags"] == window_flags.hidden_window_creation_flags()
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_checkout_inspection_hides_its_window(
|
|
110
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
111
|
+
) -> None:
|
|
112
|
+
all_captured_flags: list[object] = []
|
|
113
|
+
|
|
114
|
+
def record_run(
|
|
115
|
+
*arguments: object, **options: object
|
|
116
|
+
) -> subprocess.CompletedProcess[str]:
|
|
117
|
+
all_captured_flags.append(options["creationflags"])
|
|
118
|
+
return subprocess.CompletedProcess(arguments, 0, "abc123\n", "")
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(checkout.subprocess, "run", record_run)
|
|
121
|
+
|
|
122
|
+
local_checkout = checkout.read_local_checkout(tmp_path)
|
|
123
|
+
|
|
124
|
+
assert local_checkout.head_sha == "abc123"
|
|
125
|
+
assert all_captured_flags == [window_flags.hidden_window_creation_flags()] * 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_windows_process_host_spawn_hides_its_window(
|
|
129
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
130
|
+
) -> None:
|
|
131
|
+
registration = support._build_registration(tmp_path, tmp_path)
|
|
132
|
+
captured_options: dict[str, object] = {}
|
|
133
|
+
|
|
134
|
+
def record_popen(*arguments: object, **options: object) -> object:
|
|
135
|
+
captured_options.update(options)
|
|
136
|
+
return object()
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr(execution.subprocess, "Popen", record_popen)
|
|
139
|
+
|
|
140
|
+
execution._spawn_windows_process_host(("python", "-c", "pass"), registration, {})
|
|
141
|
+
|
|
142
|
+
expected_flags = window_flags.hidden_window_creation_flags() | getattr(
|
|
143
|
+
subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0
|
|
144
|
+
)
|
|
145
|
+
assert captured_options["creationflags"] == expected_flags
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_start_polling_launches_with_the_poller_flags(
|
|
149
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
150
|
+
) -> None:
|
|
151
|
+
captured_options: dict[str, object] = {}
|
|
152
|
+
|
|
153
|
+
def record_popen(*arguments: object, **options: object) -> object:
|
|
154
|
+
captured_options.update(options)
|
|
155
|
+
return object()
|
|
156
|
+
|
|
157
|
+
monkeypatch.setattr(cli.subprocess, "Popen", record_popen)
|
|
158
|
+
|
|
159
|
+
assert cli.start_polling(tmp_path / "settings.json") == 0
|
|
160
|
+
assert (
|
|
161
|
+
captured_options["creationflags"]
|
|
162
|
+
== window_flags.detached_poller_creation_flags()
|
|
163
|
+
)
|