claude-dev-env 2.31.0 → 2.32.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/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +2 -1
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_duplicate_body.py +280 -102
- package/hooks/blocking/code_rules_enforcer.py +32 -14
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_shared.py +64 -14
- package/hooks/blocking/pr_description_writer_gate.py +18 -25
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +11 -5
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
- package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +84 -53
- package/hooks/blocking/test_tdd_enforcer.py +131 -1
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
- package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/abbreviation_checks.py +3 -3
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +42 -8
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +23 -80
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +3 -2
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +247 -148
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +16 -16
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +104 -0
- package/scripts/test_shared_tree_paths.py +173 -0
|
@@ -8,8 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
11
|
from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
|
|
14
12
|
from hooks_constants.code_rules_path_utils_constants import ALL_CONFIG_DIRECTORY_NAMES
|
|
15
13
|
from validators.config.directory_exemption_constants import (
|
|
@@ -19,6 +17,7 @@ from validators.config.directory_exemption_constants import (
|
|
|
19
17
|
all_directory_segments_in_path_pattern,
|
|
20
18
|
directory_segment_names_from_path_patterns,
|
|
21
19
|
is_filename_like_path_segment,
|
|
20
|
+
is_pytest_scratch_directory_segment,
|
|
22
21
|
substring_patterns_from_path_patterns,
|
|
23
22
|
)
|
|
24
23
|
from validators.exempt_paths import (
|
|
@@ -36,6 +35,9 @@ ALL_SYNTHETIC_DIRECTORY_PATTERNS = frozenset(
|
|
|
36
35
|
{"/foo/", "\\bar\\", "/baz.py", "plain", "/.hidden/qux/"}
|
|
37
36
|
)
|
|
38
37
|
ALL_EXPECTED_SYNTHETIC_SEGMENTS = frozenset({"foo", "bar", ".hidden", "qux"})
|
|
38
|
+
OVER_LONG_STEM_DIRECTORY_NAME = "test_helpers_for_a_very_long_domain_name0"
|
|
39
|
+
PROJECT_ROOTED_BASETEMP_PATH = "/srv/projects/scratch-basetemp"
|
|
40
|
+
PYTEST_SCRATCH_DIRECTORY_NAME = "test_edit_introducing_new_viol0"
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
def test_is_filename_like_path_segment_detects_extensions() -> None:
|
|
@@ -45,6 +47,21 @@ def test_is_filename_like_path_segment_detects_extensions() -> None:
|
|
|
45
47
|
assert is_filename_like_path_segment(".claude") is False
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
def test_is_pytest_scratch_directory_segment_detects_numbered_test_directories() -> None:
|
|
51
|
+
assert is_pytest_scratch_directory_segment("test_drops_leading_anchor0") is True
|
|
52
|
+
assert is_pytest_scratch_directory_segment("test_edit_introducing_new_viol0") is True
|
|
53
|
+
assert is_pytest_scratch_directory_segment("pytest-of-root") is True
|
|
54
|
+
assert is_pytest_scratch_directory_segment("pytest-9") is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_is_pytest_scratch_directory_segment_keeps_real_project_directories() -> None:
|
|
58
|
+
assert is_pytest_scratch_directory_segment("test_helpers") is False
|
|
59
|
+
assert is_pytest_scratch_directory_segment("tests") is False
|
|
60
|
+
assert is_pytest_scratch_directory_segment("scripts") is False
|
|
61
|
+
assert is_pytest_scratch_directory_segment("conftest") is False
|
|
62
|
+
assert is_pytest_scratch_directory_segment(OVER_LONG_STEM_DIRECTORY_NAME) is False
|
|
63
|
+
|
|
64
|
+
|
|
48
65
|
def test_all_directory_segments_in_path_pattern_keeps_directory_tokens_only() -> None:
|
|
49
66
|
assert all_directory_segments_in_path_pattern("/.claude/hooks/") == [
|
|
50
67
|
".claude",
|
|
@@ -136,32 +153,21 @@ def test_absolute_system_temp_pytest_shaped_path_stages_flat_basename(
|
|
|
136
153
|
assert staged_path == staging_root / "legacy_module.py"
|
|
137
154
|
|
|
138
155
|
|
|
139
|
-
def
|
|
156
|
+
def test_pytest_shaped_path_stages_flat_basename_under_every_basetemp_placement(
|
|
140
157
|
tmp_path: Path,
|
|
141
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
142
158
|
) -> None:
|
|
143
|
-
"""
|
|
144
|
-
runner_temp_root = tmp_path / "runner_temp"
|
|
145
|
-
os_gettemp = tmp_path / "os_gettemp"
|
|
146
|
-
runner_temp_root.mkdir()
|
|
147
|
-
os_gettemp.mkdir()
|
|
148
|
-
monkeypatch.setenv("RUNNER_TEMP", str(runner_temp_root))
|
|
149
|
-
monkeypatch.setattr(
|
|
150
|
-
"validators.system_temporary_roots.tempfile.gettempdir",
|
|
151
|
-
lambda: str(os_gettemp),
|
|
152
|
-
)
|
|
153
|
-
pytest_shaped_target = (
|
|
154
|
-
runner_temp_root
|
|
155
|
-
/ "pytest-basetemp-pkg"
|
|
156
|
-
/ "test_edit_introducing_new_viol0"
|
|
157
|
-
/ "legacy_module.py"
|
|
158
|
-
)
|
|
159
|
+
"""A ``--basetemp`` outside every temp root must not change the staged path."""
|
|
159
160
|
staging_root = tmp_path / "staging_root"
|
|
160
161
|
staging_root.mkdir()
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
all_basetemp_roots = (tmp_path, Path(PROJECT_ROOTED_BASETEMP_PATH))
|
|
163
|
+
all_staged_paths = {
|
|
164
|
+
_temporary_path_preserving_directory_signal(
|
|
165
|
+
staging_root,
|
|
166
|
+
str(each_root / PYTEST_SCRATCH_DIRECTORY_NAME / "legacy_module.py"),
|
|
167
|
+
)
|
|
168
|
+
for each_root in all_basetemp_roots
|
|
169
|
+
}
|
|
170
|
+
assert all_staged_paths == {staging_root / "legacy_module.py"}
|
|
165
171
|
|
|
166
172
|
|
|
167
173
|
def test_substring_patterns_from_path_patterns_skips_directory_tokens() -> None:
|
|
@@ -45,7 +45,7 @@ def test_run_fast_save_validators_covers_every_non_mypy_non_ruff_check(
|
|
|
45
45
|
|
|
46
46
|
all_outcomes = run_fast_save_validators([target_file])
|
|
47
47
|
|
|
48
|
-
assert {each_outcome.
|
|
48
|
+
assert {each_outcome.name for each_outcome in all_outcomes} == (
|
|
49
49
|
_ALL_EXPECTED_FAST_SAVE_VALIDATOR_NAMES
|
|
50
50
|
)
|
|
51
51
|
|
|
@@ -56,8 +56,8 @@ def test_run_fast_save_validators_excludes_mypy_and_ruff(tmp_path: Path) -> None
|
|
|
56
56
|
|
|
57
57
|
all_outcomes = run_fast_save_validators([target_file])
|
|
58
58
|
|
|
59
|
-
assert all(each_outcome.
|
|
60
|
-
assert all(each_outcome.
|
|
59
|
+
assert all(each_outcome.name != "Mypy" for each_outcome in all_outcomes)
|
|
60
|
+
assert all(each_outcome.name != "Ruff" for each_outcome in all_outcomes)
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def test_run_fast_save_validators_spawns_no_subprocess(
|
|
@@ -98,9 +98,9 @@ def test_magic_values_outcome_matches_direct_validate_file_call(
|
|
|
98
98
|
all_outcomes = run_fast_save_validators([target_file])
|
|
99
99
|
|
|
100
100
|
magic_values_outcome = next(
|
|
101
|
-
each for each in all_outcomes if each.
|
|
101
|
+
each for each in all_outcomes if each.name == "Magic Values"
|
|
102
102
|
)
|
|
103
|
-
assert magic_values_outcome.
|
|
103
|
+
assert magic_values_outcome.output == "\n".join(expected_lines)
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
|
|
@@ -111,8 +111,8 @@ def test_todo_outcome_matches_direct_validate_file_call(tmp_path: Path) -> None:
|
|
|
111
111
|
|
|
112
112
|
all_outcomes = run_fast_save_validators([target_file])
|
|
113
113
|
|
|
114
|
-
todo_outcome = next(each for each in all_outcomes if each.
|
|
115
|
-
assert todo_outcome.
|
|
114
|
+
todo_outcome = next(each for each in all_outcomes if each.name == "TODO Tracking")
|
|
115
|
+
assert todo_outcome.output == "\n".join(expected_lines)
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) -> None:
|
|
@@ -125,9 +125,9 @@ def test_abbreviations_outcome_reports_clean_for_a_clean_file(tmp_path: Path) ->
|
|
|
125
125
|
all_outcomes = run_fast_save_validators([target_file])
|
|
126
126
|
|
|
127
127
|
abbreviations_outcome = next(
|
|
128
|
-
each for each in all_outcomes if each.
|
|
128
|
+
each for each in all_outcomes if each.name == "Abbreviations"
|
|
129
129
|
)
|
|
130
|
-
assert abbreviations_outcome.
|
|
130
|
+
assert abbreviations_outcome.passed is True
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> None:
|
|
@@ -139,8 +139,8 @@ def test_react_outcome_matches_direct_check_function_call(tmp_path: Path) -> Non
|
|
|
139
139
|
|
|
140
140
|
all_outcomes = run_fast_save_validators([target_file])
|
|
141
141
|
|
|
142
|
-
react_outcome = next(each for each in all_outcomes if each.
|
|
143
|
-
assert react_outcome.
|
|
142
|
+
react_outcome = next(each for each in all_outcomes if each.name == "React")
|
|
143
|
+
assert react_outcome.output == "\n".join(expected_lines)
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
def test_test_safety_outcome_matches_direct_check_function_calls(
|
|
@@ -155,8 +155,8 @@ def test_test_safety_outcome_matches_direct_check_function_calls(
|
|
|
155
155
|
|
|
156
156
|
all_outcomes = run_fast_save_validators([target_file])
|
|
157
157
|
|
|
158
|
-
test_safety_outcome = next(each for each in all_outcomes if each.
|
|
159
|
-
assert test_safety_outcome.
|
|
158
|
+
test_safety_outcome = next(each for each in all_outcomes if each.name == "Test Safety")
|
|
159
|
+
assert test_safety_outcome.output == "\n".join(expected_lines)
|
|
160
160
|
|
|
161
161
|
|
|
162
162
|
def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
|
|
@@ -166,7 +166,7 @@ def test_no_matching_files_reports_configured_message(tmp_path: Path) -> None:
|
|
|
166
166
|
all_outcomes = run_fast_save_validators([target_file])
|
|
167
167
|
|
|
168
168
|
abbreviations_outcome = next(
|
|
169
|
-
each for each in all_outcomes if each.
|
|
169
|
+
each for each in all_outcomes if each.name == "Abbreviations"
|
|
170
170
|
)
|
|
171
|
-
assert abbreviations_outcome.
|
|
172
|
-
assert abbreviations_outcome.
|
|
171
|
+
assert abbreviations_outcome.passed is True
|
|
172
|
+
assert abbreviations_outcome.output == "No Python files to check"
|
|
@@ -106,8 +106,16 @@ def test_ensure_real_repository_target_accepts_a_repo_file_under_the_os_temp_roo
|
|
|
106
106
|
|
|
107
107
|
|
|
108
108
|
def test_ensure_real_repository_target_refuses_a_scratch_path_outside_the_repository_root(
|
|
109
|
-
tmp_path: Path
|
|
109
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
110
110
|
) -> None:
|
|
111
|
+
"""The refusal is under test, so this pins the temp root it is judged against.
|
|
112
|
+
|
|
113
|
+
``--basetemp`` places ``tmp_path`` wherever the caller asks, so a run that
|
|
114
|
+
points it outside the OS temp root leaves the scratch target real and this
|
|
115
|
+
assertion measures the caller's flags. Naming the root here keeps the
|
|
116
|
+
subject of the test inside the test.
|
|
117
|
+
"""
|
|
118
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
111
119
|
repository_root = tmp_path / "repo"
|
|
112
120
|
repository_root.mkdir()
|
|
113
121
|
scratch_target = tmp_path / "scratch.py"
|
|
@@ -194,6 +202,7 @@ def test_measure_hosted_command_wall_times_honors_a_repository_root_override(
|
|
|
194
202
|
gap the wrapper-plumb-through check caught.
|
|
195
203
|
"""
|
|
196
204
|
monkeypatch.setattr(hook_timing_harness, "write_edit_hook_commands", lambda path: [])
|
|
205
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
|
197
206
|
repository_root = tmp_path / "repo"
|
|
198
207
|
real_file = repository_root / "hooks" / "blocking" / "code_rules_shared.py"
|
|
199
208
|
real_file.parent.mkdir(parents=True)
|
|
@@ -398,3 +398,25 @@ class TestHooksSubprocessWorkingDirectory:
|
|
|
398
398
|
)
|
|
399
399
|
assert working_directory_string == tempfile.gettempdir()
|
|
400
400
|
assert not working_directory_string.startswith("\\\\")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class TestDocstringExamplePaths:
|
|
404
|
+
def test_module_source_carries_no_home_directory_example_paths(self) -> None:
|
|
405
|
+
"""Example paths avoid /home so the privacy gate reads no personal data.
|
|
406
|
+
|
|
407
|
+
The gate allowlists a fixed set of placeholder home usernames, so an
|
|
408
|
+
invented name outside that set reads as somebody's real home directory
|
|
409
|
+
and denies the commit. Rooting an example somewhere other than /home
|
|
410
|
+
keeps the illustration and leaves nothing for the gate to flag.
|
|
411
|
+
"""
|
|
412
|
+
module_source = (Path(__file__).parent / "run_all_validators.py").read_text(
|
|
413
|
+
encoding="utf-8"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
all_home_example_lines = [
|
|
417
|
+
each_line
|
|
418
|
+
for each_line in module_source.splitlines()
|
|
419
|
+
if '"/home/' in each_line
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
assert all_home_example_lines == []
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Behavioral tests for validator coverage across every mutation tool.
|
|
2
|
+
|
|
3
|
+
The PreToolUse gate has to reach a MultiEdit and a Codex apply_patch the same
|
|
4
|
+
way it reaches a Write and an Edit. Two things carry that: the hooks.json
|
|
5
|
+
matcher the harness dispatches on, and the payload shape the gate reconstructs
|
|
6
|
+
post-edit content from.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
16
|
+
ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .run_all_validators import run_validators_entrypoint_subprocess
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
22
|
+
|
|
23
|
+
HOOKS_JSON_PATH = Path(__file__).resolve().parents[1] / "hooks.json"
|
|
24
|
+
VALIDATORS_RUNNER_COMMAND_MARKER = "validators.run_all_validators"
|
|
25
|
+
MATCHER_TOOL_NAME_SEPARATOR = "|"
|
|
26
|
+
DENY_DECISION_FRAGMENT = '"permissionDecision": "deny"'
|
|
27
|
+
MAGIC_VALUE_VALIDATOR_NAME = "Magic Values"
|
|
28
|
+
|
|
29
|
+
CLEAN_MODULE_SOURCE = (
|
|
30
|
+
"def clean_marker() -> int:\n computed_value = 1\n return computed_value\n"
|
|
31
|
+
)
|
|
32
|
+
CLEAN_SECOND_MODULE_SOURCE = (
|
|
33
|
+
"def second_marker() -> int:\n counted_value = 1\n return counted_value\n"
|
|
34
|
+
)
|
|
35
|
+
CLEAN_RETURN_LINE = " return computed_value\n"
|
|
36
|
+
MAGIC_VALUE_RETURN_LINE = " return computed_value * 199\n"
|
|
37
|
+
CLEAN_REWRITTEN_RETURN_LINE = " return computed_value + 1\n"
|
|
38
|
+
|
|
39
|
+
UPDATE_PATCH_TEMPLATE = (
|
|
40
|
+
"*** Begin Patch\n"
|
|
41
|
+
"*** Update File: {relative_path}\n"
|
|
42
|
+
"@@\n"
|
|
43
|
+
"-{removed_line}"
|
|
44
|
+
"+{added_line}"
|
|
45
|
+
"*** End Patch\n"
|
|
46
|
+
)
|
|
47
|
+
ADD_VIOLATING_FILE_PATCH_COMMAND = (
|
|
48
|
+
"*** Begin Patch\n"
|
|
49
|
+
"*** Add File: added_module.py\n"
|
|
50
|
+
"+def added_marker() -> int:\n"
|
|
51
|
+
"+ return 199\n"
|
|
52
|
+
"*** End Patch\n"
|
|
53
|
+
)
|
|
54
|
+
DELETE_FILE_PATCH_COMMAND = (
|
|
55
|
+
"*** Begin Patch\n*** Delete File: legacy_module.py\n*** End of File\n*** End Patch\n"
|
|
56
|
+
)
|
|
57
|
+
UNPARSEABLE_PATCH_COMMAND = "not a codex patch at all\n"
|
|
58
|
+
TWO_FILE_PATCH_COMMAND = (
|
|
59
|
+
"*** Begin Patch\n"
|
|
60
|
+
"*** Update File: first_module.py\n"
|
|
61
|
+
"@@\n"
|
|
62
|
+
"- return computed_value\n"
|
|
63
|
+
"+ return computed_value + 1\n"
|
|
64
|
+
"*** Update File: second_module.py\n"
|
|
65
|
+
"@@\n"
|
|
66
|
+
"- return counted_value\n"
|
|
67
|
+
"+ return counted_value * 199\n"
|
|
68
|
+
"*** End Patch\n"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_gate(payload: dict[str, object]) -> "subprocess.CompletedProcess[str]":
|
|
73
|
+
"""Drive the real --pre-tool-use entry point with one payload."""
|
|
74
|
+
return run_validators_entrypoint_subprocess(["--pre-tool-use"], stdin_text=json.dumps(payload))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def update_patch_command(relative_path: str, added_line: str) -> str:
|
|
78
|
+
"""Build an Update File patch rewriting the module's return line."""
|
|
79
|
+
return UPDATE_PATCH_TEMPLATE.format(
|
|
80
|
+
relative_path=relative_path,
|
|
81
|
+
removed_line=CLEAN_RETURN_LINE,
|
|
82
|
+
added_line=added_line,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_clean_module(target_directory: Path, module_name: str) -> Path:
|
|
87
|
+
"""Write the clean Python module the payloads target."""
|
|
88
|
+
target_file = target_directory / module_name
|
|
89
|
+
target_file.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
90
|
+
return target_file
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestMutationToolRegistration:
|
|
94
|
+
def test_validators_gate_is_registered_on_every_mutation_tool(self) -> None:
|
|
95
|
+
hooks_document = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
|
|
96
|
+
all_matchers = [
|
|
97
|
+
each_group["matcher"]
|
|
98
|
+
for each_group in hooks_document["hooks"]["PreToolUse"]
|
|
99
|
+
for each_hook in each_group.get("hooks", [])
|
|
100
|
+
if VALIDATORS_RUNNER_COMMAND_MARKER in each_hook.get("command", "")
|
|
101
|
+
]
|
|
102
|
+
assert len(all_matchers) == 1, all_matchers
|
|
103
|
+
assert (
|
|
104
|
+
set(all_matchers[0].split(MATCHER_TOOL_NAME_SEPARATOR))
|
|
105
|
+
== ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestMultiEditPayloadLane:
|
|
110
|
+
def test_multi_edit_introducing_a_violation_denies(self, tmp_path: Path) -> None:
|
|
111
|
+
target_file = write_clean_module(tmp_path, "legacy_module.py")
|
|
112
|
+
completed = run_gate(
|
|
113
|
+
{
|
|
114
|
+
"tool_name": "MultiEdit",
|
|
115
|
+
"tool_input": {
|
|
116
|
+
"file_path": str(target_file),
|
|
117
|
+
"edits": [
|
|
118
|
+
{
|
|
119
|
+
"old_string": CLEAN_RETURN_LINE,
|
|
120
|
+
"new_string": MAGIC_VALUE_RETURN_LINE,
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
assert completed.returncode == 0, completed.stderr
|
|
127
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
128
|
+
assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
|
|
129
|
+
|
|
130
|
+
def test_multi_edit_leaving_clean_content_allows(self, tmp_path: Path) -> None:
|
|
131
|
+
target_file = write_clean_module(tmp_path, "clean_module.py")
|
|
132
|
+
completed = run_gate(
|
|
133
|
+
{
|
|
134
|
+
"tool_name": "MultiEdit",
|
|
135
|
+
"tool_input": {
|
|
136
|
+
"file_path": str(target_file),
|
|
137
|
+
"edits": [
|
|
138
|
+
{
|
|
139
|
+
"old_string": CLEAN_RETURN_LINE,
|
|
140
|
+
"new_string": CLEAN_REWRITTEN_RETURN_LINE,
|
|
141
|
+
}
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
assert completed.returncode == 0, completed.stderr
|
|
147
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestApplyPatchPayloadLane:
|
|
151
|
+
def test_apply_patch_introducing_a_violation_denies(self, tmp_path: Path) -> None:
|
|
152
|
+
write_clean_module(tmp_path, "legacy_module.py")
|
|
153
|
+
completed = run_gate(
|
|
154
|
+
{
|
|
155
|
+
"tool_name": "apply_patch",
|
|
156
|
+
"tool_input": {
|
|
157
|
+
"command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
|
|
158
|
+
},
|
|
159
|
+
"cwd": str(tmp_path),
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
assert completed.returncode == 0, completed.stderr
|
|
163
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
164
|
+
assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
|
|
165
|
+
|
|
166
|
+
def test_apply_patch_deny_reason_names_the_patched_file(self, tmp_path: Path) -> None:
|
|
167
|
+
target_file = write_clean_module(tmp_path, "legacy_module.py")
|
|
168
|
+
completed = run_gate(
|
|
169
|
+
{
|
|
170
|
+
"tool_name": "apply_patch",
|
|
171
|
+
"tool_input": {
|
|
172
|
+
"command": update_patch_command("legacy_module.py", MAGIC_VALUE_RETURN_LINE)
|
|
173
|
+
},
|
|
174
|
+
"cwd": str(tmp_path),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
assert completed.returncode == 0, completed.stderr
|
|
178
|
+
assert target_file.name in completed.stdout
|
|
179
|
+
|
|
180
|
+
def test_apply_patch_leaving_clean_content_allows(self, tmp_path: Path) -> None:
|
|
181
|
+
write_clean_module(tmp_path, "clean_module.py")
|
|
182
|
+
completed = run_gate(
|
|
183
|
+
{
|
|
184
|
+
"tool_name": "apply_patch",
|
|
185
|
+
"tool_input": {
|
|
186
|
+
"command": update_patch_command("clean_module.py", CLEAN_REWRITTEN_RETURN_LINE)
|
|
187
|
+
},
|
|
188
|
+
"cwd": str(tmp_path),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
assert completed.returncode == 0, completed.stderr
|
|
192
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
193
|
+
|
|
194
|
+
def test_apply_patch_adding_a_violating_file_denies(self, tmp_path: Path) -> None:
|
|
195
|
+
completed = run_gate(
|
|
196
|
+
{
|
|
197
|
+
"tool_name": "apply_patch",
|
|
198
|
+
"tool_input": {"command": ADD_VIOLATING_FILE_PATCH_COMMAND},
|
|
199
|
+
"cwd": str(tmp_path),
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
assert completed.returncode == 0, completed.stderr
|
|
203
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
204
|
+
assert MAGIC_VALUE_VALIDATOR_NAME in completed.stdout
|
|
205
|
+
|
|
206
|
+
def test_apply_patch_deleting_a_file_allows(self, tmp_path: Path) -> None:
|
|
207
|
+
write_clean_module(tmp_path, "legacy_module.py")
|
|
208
|
+
completed = run_gate(
|
|
209
|
+
{
|
|
210
|
+
"tool_name": "apply_patch",
|
|
211
|
+
"tool_input": {"command": DELETE_FILE_PATCH_COMMAND},
|
|
212
|
+
"cwd": str(tmp_path),
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
assert completed.returncode == 0, completed.stderr
|
|
216
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
217
|
+
|
|
218
|
+
def test_unparseable_apply_patch_command_allows(self, tmp_path: Path) -> None:
|
|
219
|
+
"""A patch this lane cannot read is the enforcer's to deny, not this gate's.
|
|
220
|
+
|
|
221
|
+
``code_rules_enforcer`` denies a payload whose patch markers it cannot
|
|
222
|
+
parse. Both hooks run on the same call and any denial wins, so this lane
|
|
223
|
+
allows rather than raising a second denial for one cause. Moving that
|
|
224
|
+
denial out of the enforcer leaves a malformed patch with no reader.
|
|
225
|
+
"""
|
|
226
|
+
completed = run_gate(
|
|
227
|
+
{
|
|
228
|
+
"tool_name": "apply_patch",
|
|
229
|
+
"tool_input": {"command": UNPARSEABLE_PATCH_COMMAND},
|
|
230
|
+
"cwd": str(tmp_path),
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
assert completed.returncode == 0, completed.stderr
|
|
234
|
+
assert DENY_DECISION_FRAGMENT not in completed.stdout
|
|
235
|
+
|
|
236
|
+
def test_apply_patch_denies_a_violation_in_its_second_file(self, tmp_path: Path) -> None:
|
|
237
|
+
write_clean_module(tmp_path, "first_module.py")
|
|
238
|
+
second_file = tmp_path / "second_module.py"
|
|
239
|
+
second_file.write_text(CLEAN_SECOND_MODULE_SOURCE, encoding="utf-8")
|
|
240
|
+
completed = run_gate(
|
|
241
|
+
{
|
|
242
|
+
"tool_name": "apply_patch",
|
|
243
|
+
"tool_input": {"command": TWO_FILE_PATCH_COMMAND},
|
|
244
|
+
"cwd": str(tmp_path),
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
assert completed.returncode == 0, completed.stderr
|
|
248
|
+
assert DENY_DECISION_FRAGMENT in completed.stdout
|
|
249
|
+
assert second_file.name in completed.stdout
|
|
@@ -19,6 +19,7 @@ from .run_all_validators import (
|
|
|
19
19
|
ValidatorResult,
|
|
20
20
|
_escapes_temporary_root,
|
|
21
21
|
_hooks_subprocess_working_directory_and_environment,
|
|
22
|
+
_scope_agnostic_message,
|
|
22
23
|
_scope_new_and_preexisting,
|
|
23
24
|
_temporary_path_preserving_directory_signal,
|
|
24
25
|
_violation_line_number,
|
|
@@ -33,6 +34,9 @@ CONFIG_DIR_TARGET_PATH = (
|
|
|
33
34
|
)
|
|
34
35
|
PARENT_TRAVERSAL_TARGET_PATH = "../../escape_target.py"
|
|
35
36
|
RELATIVE_CONFIG_TARGET_PATH = "config/x.py"
|
|
37
|
+
PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH = (
|
|
38
|
+
"/srv/projects/demo/test_edit_introducing_new_viol0/legacy_module.py"
|
|
39
|
+
)
|
|
36
40
|
|
|
37
41
|
CLEAN_PYTHON_SOURCE = (
|
|
38
42
|
"def add_two_numbers(first_number: int, second_number: int) -> int:\n"
|
|
@@ -228,6 +232,38 @@ class TestTemporaryPathPreservingDirectorySignal:
|
|
|
228
232
|
)
|
|
229
233
|
assert staged_path == staging_root / "legacy_module.py"
|
|
230
234
|
|
|
235
|
+
def test_pytest_scratch_segment_stages_flat_outside_every_temporary_root(
|
|
236
|
+
self, tmp_path: Path
|
|
237
|
+
) -> None:
|
|
238
|
+
staging_root = tmp_path / "staging_root"
|
|
239
|
+
staging_root.mkdir()
|
|
240
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
241
|
+
staging_root, PROJECT_ROOTED_PYTEST_SCRATCH_TARGET_PATH
|
|
242
|
+
)
|
|
243
|
+
assert staged_path == staging_root / "legacy_module.py"
|
|
244
|
+
|
|
245
|
+
def test_test_helpers_directory_keeps_its_exemption_under_a_temporary_root(
|
|
246
|
+
self, tmp_path: Path
|
|
247
|
+
) -> None:
|
|
248
|
+
helpers_target = tmp_path / "pkg" / "test_helpers" / "worker.py"
|
|
249
|
+
staging_root = tmp_path / "staging_root"
|
|
250
|
+
staging_root.mkdir()
|
|
251
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
252
|
+
staging_root, str(helpers_target)
|
|
253
|
+
)
|
|
254
|
+
assert staged_path == staging_root / "test_helpers" / "worker.py"
|
|
255
|
+
|
|
256
|
+
def test_scripts_directory_keeps_its_exemption_under_a_temporary_root(
|
|
257
|
+
self, tmp_path: Path
|
|
258
|
+
) -> None:
|
|
259
|
+
scripts_target = tmp_path / "pkg" / "scripts" / "run_job.py"
|
|
260
|
+
staging_root = tmp_path / "staging_root"
|
|
261
|
+
staging_root.mkdir()
|
|
262
|
+
staged_path = _temporary_path_preserving_directory_signal(
|
|
263
|
+
staging_root, str(scripts_target)
|
|
264
|
+
)
|
|
265
|
+
assert staged_path == staging_root / "scripts" / "run_job.py"
|
|
266
|
+
|
|
231
267
|
def test_relative_config_path_preserves_config_under_system_temp_cwd(
|
|
232
268
|
self, monkeypatch: pytest.MonkeyPatch
|
|
233
269
|
) -> None:
|
|
@@ -430,6 +466,100 @@ class TestBaselineScopedGate:
|
|
|
430
466
|
assert '"permissionDecision": "deny"' in completed.stdout
|
|
431
467
|
assert "Magic Values" in completed.stdout
|
|
432
468
|
|
|
469
|
+
def test_renaming_a_function_keeps_its_violation_grandfathered(
|
|
470
|
+
self, tmp_path: Path
|
|
471
|
+
) -> None:
|
|
472
|
+
target_file = tmp_path / "legacy_module.py"
|
|
473
|
+
target_file.write_text(
|
|
474
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
475
|
+
)
|
|
476
|
+
completed = run_gate(
|
|
477
|
+
{
|
|
478
|
+
"tool_name": "Edit",
|
|
479
|
+
"tool_input": {
|
|
480
|
+
"file_path": str(target_file),
|
|
481
|
+
"old_string": "def over_long_function(",
|
|
482
|
+
"new_string": "def accumulate_running_total(",
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
assert completed.returncode == 0, completed.stderr
|
|
487
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
488
|
+
assert "Code Quality" in completed.stderr
|
|
489
|
+
|
|
490
|
+
def test_renaming_a_function_denies_a_second_violation_of_the_same_shape(
|
|
491
|
+
self, tmp_path: Path
|
|
492
|
+
) -> None:
|
|
493
|
+
target_file = tmp_path / "legacy_module.py"
|
|
494
|
+
target_file.write_text(
|
|
495
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
496
|
+
)
|
|
497
|
+
completed = run_gate(
|
|
498
|
+
{
|
|
499
|
+
"tool_name": "Edit",
|
|
500
|
+
"tool_input": {
|
|
501
|
+
"file_path": str(target_file),
|
|
502
|
+
"old_string": "def over_long_function(",
|
|
503
|
+
"new_string": (
|
|
504
|
+
_over_long_function_source("second_over_long_function")
|
|
505
|
+
+ "\n\n"
|
|
506
|
+
+ "def accumulate_running_total("
|
|
507
|
+
),
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
)
|
|
511
|
+
assert completed.returncode == 0, completed.stderr
|
|
512
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
513
|
+
assert "Code Quality" in completed.stdout
|
|
514
|
+
|
|
515
|
+
def test_renaming_a_function_denies_a_new_violation_of_another_validator(
|
|
516
|
+
self, tmp_path: Path
|
|
517
|
+
) -> None:
|
|
518
|
+
target_file = tmp_path / "legacy_module.py"
|
|
519
|
+
target_file.write_text(
|
|
520
|
+
_over_long_function_source("over_long_function"), encoding="utf-8"
|
|
521
|
+
)
|
|
522
|
+
completed = run_gate(
|
|
523
|
+
{
|
|
524
|
+
"tool_name": "Edit",
|
|
525
|
+
"tool_input": {
|
|
526
|
+
"file_path": str(target_file),
|
|
527
|
+
"old_string": "def over_long_function(",
|
|
528
|
+
"new_string": "import os\n\n\ndef accumulate_running_total(",
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
assert completed.returncode == 0, completed.stderr
|
|
533
|
+
assert '"permissionDecision": "deny"' in completed.stdout
|
|
534
|
+
assert "F401" in completed.stdout
|
|
535
|
+
|
|
536
|
+
def test_moving_a_violation_to_another_function_keeps_it_grandfathered(
|
|
537
|
+
self, tmp_path: Path
|
|
538
|
+
) -> None:
|
|
539
|
+
target_file = tmp_path / "legacy_module.py"
|
|
540
|
+
target_file.write_text(RUFF_DIRTY_FUNCTION_SOURCE, encoding="utf-8")
|
|
541
|
+
completed = run_gate(
|
|
542
|
+
{
|
|
543
|
+
"tool_name": "Edit",
|
|
544
|
+
"tool_input": {
|
|
545
|
+
"file_path": str(target_file),
|
|
546
|
+
"old_string": RUFF_DIRTY_FUNCTION_SOURCE,
|
|
547
|
+
"new_string": (
|
|
548
|
+
"def compute_total() -> int:\n"
|
|
549
|
+
" return 1\n"
|
|
550
|
+
"\n"
|
|
551
|
+
"\n"
|
|
552
|
+
"def compute_subtotal() -> int:\n"
|
|
553
|
+
" unused_intermediate = 1\n"
|
|
554
|
+
" return 1\n"
|
|
555
|
+
),
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
)
|
|
559
|
+
assert completed.returncode == 0, completed.stderr
|
|
560
|
+
assert '"permissionDecision": "deny"' not in completed.stdout
|
|
561
|
+
assert "F841" in completed.stderr
|
|
562
|
+
|
|
433
563
|
def test_new_module_scope_ruff_violation_with_dirty_function_denies(
|
|
434
564
|
self, tmp_path: Path
|
|
435
565
|
) -> None:
|
|
@@ -451,6 +581,37 @@ class TestBaselineScopedGate:
|
|
|
451
581
|
assert "F841" not in completed.stdout
|
|
452
582
|
|
|
453
583
|
|
|
584
|
+
class TestScopeAgnosticMessage:
|
|
585
|
+
def test_masks_the_enclosing_function_name_and_its_metrics(self) -> None:
|
|
586
|
+
masked_message = _scope_agnostic_message(
|
|
587
|
+
"over_long_function", "Function 'over_long_function' is 37 lines (max 30)"
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
assert masked_message == "Function '<name>' is <number> lines (max <number>)"
|
|
591
|
+
|
|
592
|
+
def test_keeps_the_numbers_of_a_message_that_never_names_its_function(self) -> None:
|
|
593
|
+
magic_value_message = "Magic number 199 - use named constant"
|
|
594
|
+
|
|
595
|
+
masked_message = _scope_agnostic_message("compute_total", magic_value_message)
|
|
596
|
+
|
|
597
|
+
assert masked_message == magic_value_message
|
|
598
|
+
|
|
599
|
+
def test_keeps_a_module_scope_message_unchanged(self) -> None:
|
|
600
|
+
ruff_message = "F401 `os` imported but unused"
|
|
601
|
+
|
|
602
|
+
masked_message = _scope_agnostic_message("", ruff_message)
|
|
603
|
+
|
|
604
|
+
assert masked_message == ruff_message
|
|
605
|
+
|
|
606
|
+
def test_masks_only_the_innermost_name_of_a_qualified_scope(self) -> None:
|
|
607
|
+
masked_message = _scope_agnostic_message(
|
|
608
|
+
"Ledger.record_entry",
|
|
609
|
+
"Parameter 'entry' in 'record_entry' missing type annotation",
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
assert masked_message == "Parameter 'entry' in '<name>' missing type annotation"
|
|
613
|
+
|
|
614
|
+
|
|
454
615
|
class TestScopeNewAndPreexisting:
|
|
455
616
|
def test_failed_result_without_located_lines_is_treated_as_new(self) -> None:
|
|
456
617
|
summary_only_result = ValidatorResult(
|