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